Version: 0.9.45.dev.260427

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

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

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

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

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"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
}

View File

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

View File

@@ -337,6 +337,17 @@ func buildTimeGrid(schedules []model.Schedule, taskClass *model.TaskClass) *grid
}
}
}
// 标记整天屏蔽:
// 1. excluded_days_of_week 表示“这些星期几整天都不允许粗排”;
// 2. 与 excluded_slots 一样属于硬约束,因此直接写入 Blocked
// 3. 一旦工作日容量不足,粗排应直接失败,而不是偷偷排到被排除的星期里。
for _, blockedDay := range taskClass.ExcludedDaysOfWeek {
for w := startW; w <= endW; w++ {
for s := 1; s <= 12; s++ {
g.setNode(w, blockedDay, s, slotNode{Status: Blocked})
}
}
}
// 映射日程 (尊重 Blocked 且只处理范围内的数据)
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 rapidgap=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
}

View File

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

View File

@@ -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 恢复 PendingConfirmToolPhase=executing
- reject有 PendingTool不恢复 PendingConfirmToolPhase=executingLLM 换方案)
- 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`)
**职责**:生成最终总结
- 调 LLMtemperature=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 → Plancontinue继续规划
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. 构建 AgentGraphRequestConfirmAction 从 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` |

View File

@@ -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. 第一阶段V1WebSearch + 简单抓取
### 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. 第二阶段V2WebRAG 语义召回
### 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. M11~2 天V1 工具跑通,联调 Execute 节点可调用。
2. M22~4 天V1 稳定性优化(超时/限流/日志/错误码)。
3. M34~7 天V2 WebRAG MVP混合召回+基础重排+证据输出)。
4. M4后续统一 RAG Core打通记忆系统复用。

View File

@@ -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` 清理。

View File

@@ -1,575 +0,0 @@
GOROOT=C:\Program Files\Go #gosetup
GOPATH=C:\Users\Dev\go #gosetup
"C:\Program Files\Go\bin\go.exe" build -o C:\Users\Dev\AppData\Local\JetBrains\GoLand2025.3\tmp\GoLand\___6go_build_main_go.exe D:\SmartFlow-Agent\backend\main.go #gosetup
C:\Users\Dev\AppData\Local\JetBrains\GoLand2025.3\tmp\GoLand\___6go_build_main_go.exe #gosetup
2026/04/18 10:03:36 Config loaded successfully
2026/04/18 10:03:45 Database connected successfully
2026/04/18 10:03:45 Database auto migration completed
2026/04/18 10:03:45 RAG runtime initialized: store=milvus embed=eino reranker=noop
2026/04/18 10:03:45 outbox engine starting: topic=smartflow.agent.outbox brokers=[localhost:9092] retry_scan=1s batch=100
2026/04/18 10:03:45 Kafka topic is ready: smartflow.agent.outbox
2026/04/18 10:03:45 Outbox event bus started
2026/04/18 10:03:45 Memory worker started
2026/04/18 10:03:45 WebSearch provider: bocha
2026/04/18 10:03:45 Routes setup completed
2026/04/18 10:03:45 Server starting on port 8080...
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.
[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
- using env: export GIN_MODE=release
- using code: gin.SetMode(gin.ReleaseMode)
[GIN-debug] GET /api/v1/health --> github.com/LoveLosita/smartflow/backend/routers.RegisterRouters.func1 (3 handlers)
[GIN-debug] POST /api/v1/user/register --> github.com/LoveLosita/smartflow/backend/api.(*UserHandler).UserRegister-fm (3 handlers)
[GIN-debug] POST /api/v1/user/login --> github.com/LoveLosita/smartflow/backend/api.(*UserHandler).UserLogin-fm (3 handlers)
[GIN-debug] POST /api/v1/user/refresh-token --> github.com/LoveLosita/smartflow/backend/api.(*UserHandler).RefreshTokenHandler-fm (3 handlers)
[GIN-debug] POST /api/v1/user/logout --> github.com/LoveLosita/smartflow/backend/api.(*UserHandler).UserLogout-fm (5 handlers)
[GIN-debug] POST /api/v1/task/create --> github.com/LoveLosita/smartflow/backend/api.(*TaskHandler).AddTask-fm (6 handlers)
[GIN-debug] PUT /api/v1/task/complete --> github.com/LoveLosita/smartflow/backend/api.(*TaskHandler).CompleteTask-fm (6 handlers)
[GIN-debug] PUT /api/v1/task/undo-complete --> github.com/LoveLosita/smartflow/backend/api.(*TaskHandler).UndoCompleteTask-fm (6 handlers)
[GIN-debug] GET /api/v1/task/get --> github.com/LoveLosita/smartflow/backend/api.(*TaskHandler).GetUserTasks-fm (5 handlers)
[GIN-debug] POST /api/v1/course/validate --> github.com/LoveLosita/smartflow/backend/api.(*CourseHandler).CheckUserCourse-fm (5 handlers)
[GIN-debug] POST /api/v1/course/import --> github.com/LoveLosita/smartflow/backend/api.(*CourseHandler).AddUserCourses-fm (6 handlers)
[GIN-debug] POST /api/v1/task-class/add --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).UserAddTaskClass-fm (6 handlers)
[GIN-debug] GET /api/v1/task-class/list --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).UserGetTaskClassInfos-fm (5 handlers)
[GIN-debug] GET /api/v1/task-class/get --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).UserGetCompleteTaskClass-fm (5 handlers)
[GIN-debug] PUT /api/v1/task-class/update --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).UserUpdateTaskClass-fm (6 handlers)
[GIN-debug] POST /api/v1/task-class/insert-into-schedule --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).UserAddTaskClassItemIntoSchedule-fm (6 handlers)
[GIN-debug] DELETE /api/v1/task-class/delete-item --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).DeleteTaskClassItem-fm (6 handlers)
[GIN-debug] DELETE /api/v1/task-class/delete-class --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).DeleteTaskClass-fm (6 handlers)
[GIN-debug] PUT /api/v1/task-class/apply-batch-into-schedule --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).UserInsertBatchTaskClassItemsIntoSchedule-fm (6 handlers)
[GIN-debug] GET /api/v1/schedule/today --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).GetUserTodaySchedule-fm (5 handlers)
[GIN-debug] GET /api/v1/schedule/week --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).GetUserWeeklySchedule-fm (5 handlers)
[GIN-debug] DELETE /api/v1/schedule/delete --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).DeleteScheduleEvent-fm (6 handlers)
[GIN-debug] GET /api/v1/schedule/recent-completed --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).GetUserRecentCompletedSchedules-fm (5 handlers)
[GIN-debug] GET /api/v1/schedule/current --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).GetUserOngoingSchedule-fm (5 handlers)
[GIN-debug] DELETE /api/v1/schedule/undo-task-item --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).UserRevocateTaskItemFromSchedule-fm (6 handlers)
[GIN-debug] GET /api/v1/schedule/smart-planning --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).SmartPlanning-fm (5 handlers)
[GIN-debug] POST /api/v1/schedule/smart-planning-multi --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).SmartPlanningMulti-fm (5 handlers)
[GIN-debug] POST /api/v1/agent/chat --> github.com/LoveLosita/smartflow/backend/api.(*AgentHandler).ChatAgent-fm (6 handlers)
[GIN-debug] GET /api/v1/agent/conversation-meta --> github.com/LoveLosita/smartflow/backend/api.(*AgentHandler).GetConversationMeta-fm (5 handlers)
[GIN-debug] GET /api/v1/agent/conversation-list --> github.com/LoveLosita/smartflow/backend/api.(*AgentHandler).GetConversationList-fm (5 handlers)
[GIN-debug] GET /api/v1/agent/conversation-history --> github.com/LoveLosita/smartflow/backend/api.(*AgentHandler).GetConversationHistory-fm (5 handlers)
[GIN-debug] GET /api/v1/agent/schedule-preview --> github.com/LoveLosita/smartflow/backend/api.(*AgentHandler).GetSchedulePlanPreview-fm (5 handlers)
[GIN-debug] GET /api/v1/agent/context-stats --> github.com/LoveLosita/smartflow/backend/api.(*AgentHandler).GetContextStats-fm (5 handlers)
[GIN-debug] GET /api/v1/memory/items --> github.com/LoveLosita/smartflow/backend/api.(*MemoryHandler).ListItems-fm (5 handlers)
[GIN-debug] GET /api/v1/memory/items/:id --> github.com/LoveLosita/smartflow/backend/api.(*MemoryHandler).GetItem-fm (5 handlers)
[GIN-debug] POST /api/v1/memory/items --> github.com/LoveLosita/smartflow/backend/api.(*MemoryHandler).CreateItem-fm (6 handlers)
[GIN-debug] PATCH /api/v1/memory/items/:id --> github.com/LoveLosita/smartflow/backend/api.(*MemoryHandler).UpdateItem-fm (6 handlers)
[GIN-debug] DELETE /api/v1/memory/items/:id --> github.com/LoveLosita/smartflow/backend/api.(*MemoryHandler).DeleteItem-fm (6 handlers)
[GIN-debug] POST /api/v1/memory/items/:id/restore --> github.com/LoveLosita/smartflow/backend/api.(*MemoryHandler).RestoreItem-fm (6 handlers)
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
Please check https://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details.
[GIN-debug] Listening and serving HTTP on :8080
[GIN] 2026/04/18 - 10:03:47 | 200 | 56.2777ms | 127.0.0.1 | GET "/api/v1/agent/conversation-list?page=1&page_size=12&limit=12&status=active"
[GIN] 2026/04/18 - 10:03:48 | 200 | 51.0388ms | 127.0.0.1 | GET "/api/v1/agent/conversation-history?conversation_id=1655dd9b-2c4c-4b56-a712-f34c11b2634d"
[GIN] 2026/04/18 - 10:03:48 | 200 | 2.0207ms | 127.0.0.1 | GET "/api/v1/agent/conversation-meta?conversation_id=1655dd9b-2c4c-4b56-a712-f34c11b2634d"
[GIN] 2026/04/18 - 10:03:48 | 200 | 47.1267ms | 127.0.0.1 | GET "/api/v1/agent/context-stats?conversation_id=1655dd9b-2c4c-4b56-a712-f34c11b2634d"
[GIN] 2026/04/18 - 10:03:56 | 200 | 49.8019ms | 127.0.0.1 | GET "/api/v1/agent/conversation-history?conversation_id=7c7454e9-e335-4073-b0a2-dba0fdb61831"
[GIN] 2026/04/18 - 10:03:56 | 200 | 2.3995ms | 127.0.0.1 | GET "/api/v1/agent/context-stats?conversation_id=7c7454e9-e335-4073-b0a2-dba0fdb61831"
[GIN] 2026/04/18 - 10:03:56 | 200 | 9.1263ms | 127.0.0.1 | GET "/api/v1/agent/conversation-meta?conversation_id=7c7454e9-e335-4073-b0a2-dba0fdb61831"
[GIN] 2026/04/18 - 10:03:57 | 200 | 2.2448ms | 127.0.0.1 | GET "/api/v1/agent/conversation-meta?conversation_id=905d0549-c099-42aa-8ab1-e5153543e6d0"
[GIN] 2026/04/18 - 10:03:57 | 200 | 48.1556ms | 127.0.0.1 | GET "/api/v1/agent/context-stats?conversation_id=905d0549-c099-42aa-8ab1-e5153543e6d0"
[GIN] 2026/04/18 - 10:03:57 | 200 | 48.1556ms | 127.0.0.1 | GET "/api/v1/agent/conversation-history?conversation_id=905d0549-c099-42aa-8ab1-e5153543e6d0"
[GIN] 2026/04/18 - 10:03:57 | 200 | 49.2902ms | 127.0.0.1 | GET "/api/v1/agent/conversation-history?conversation_id=929fc727-291b-4f18-a5b7-aeda2abde1e3"
[GIN] 2026/04/18 - 10:03:57 | 200 | 1.4866ms | 127.0.0.1 | GET "/api/v1/agent/context-stats?conversation_id=929fc727-291b-4f18-a5b7-aeda2abde1e3"
[GIN] 2026/04/18 - 10:03:57 | 200 | 4.8978ms | 127.0.0.1 | GET "/api/v1/agent/conversation-meta?conversation_id=929fc727-291b-4f18-a5b7-aeda2abde1e3"
2026/04/18 10:04:06 D:/SmartFlow-Agent/backend/dao/agent.go:211 record not found
[47.428ms] [rows:0] SELECT * FROM `agent_chats` WHERE user_id = 1 AND chat_id = '6c0edfe9-2dba-4927-905b-bfdb06e19e2a' ORDER BY `agent_chats`.`id` LIMIT 1
2026/04/18 10:04:06 [GORM-Cache] Invalidated conversation history cache for user 1 conversation 6c0edfe9-2dba-4927-905b-bfdb06e19e2a
2026/04/18 10:04:06 [DEBUG] loadOrCreateRuntimeState chatID=6c0edfe9-2dba-4927-905b-bfdb06e19e2a ok=false err=<nil> hasRuntime=false hasPending=false hasCtx=false hasSchedule=false hasOriginal=false
2026/04/18 10:04:06 [INFO] memory prefetch: 启动后台检索 goroutine user=1 chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a
2026/04/18 10:04:06 [COMPACT:chat] token budget check: total=1619 budget=80000 over=false compactMsg1=false compactMsg2=false (msg0=1512 msg1=20 msg2=14 msg3=73)
2026/04/18 10:04:06 [DEBUG] chat LLM context begin phase=routing chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a round=0 message_count=4
----- message[0] -----
role: system
content:
你叫 SmartMate是时伴SmartMate的中文 AI 排程伙伴,面向大学生提供陪伴式日程管理与日常协助。
你擅长课表与任务安排、任务管理、学习规划和随口记,也可以正常回答日常问答、生活建议、信息整理、分析讨论等非排程问题。
你的目标是像一个越用越懂用户的伙伴一样,结合历史对话、长期记忆和当前上下文,给出贴心、清晰、可信的帮助。
你的回复应当专业、自然、有陪伴感,偶尔可以带一点轻松幽默。
如果用户的问题与日程无关,不要因为“不属于排程”就拒绝、回避或强行转到任务安排;只要不需要工具且你有把握,就直接回答。
重要约束:你无法直接写入数据库。除非系统明确告知“任务已落库成功”,否则禁止使用“已安排/已记录/已帮你记下”等完成态表述。
你是 SmartMate 的聊天路由助手。SmartMate 是时伴SmartMate的中文 AI 排程伙伴,面向大学生提供陪伴式日程管理与日常协助;它擅长日程安排、任务管理与学习规划,但不只会做排程。你的回复必须以路由控制码开头,控制码后紧跟用户可见的内容。
路由规则:
- direct_reply纯闲聊、简单问答、轻量生活建议、打招呼、感谢等不需要工具、也不需要长链路思考的请求。控制码后直接输出完整回复。
- execute需要用工具处理的请求记录任务/提醒、查询日程、移动课程、排课等),但不需要先制定计划。控制码后输出简短确认。
- deep_answer复杂问题但不需要工具如分析建议、知识解释、方案比较、深度讨论等需要深度思考后回答。控制码后不要输出任何占位过渡语后端会直接进入第二次正式回答。
- plan用户明确要求先制定计划或涉及多阶段复杂规划。控制码后输出简短确认。
通用回答约束:
- 非日程、非任务类问题,只要不需要工具,也应当正常回答。
- 不要因为用户的问题不涉及排程,就说自己“只能处理日程/任务安排”。
- 不要把普通问答、生活建议、开放式讨论,硬拐成排程请求。
- route=direct_reply 时,控制码后的可见内容应直接回应用户问题,而不是先讲能力边界。
- route=deep_answer 时,只输出控制码即可,不要补“让我想想”“这是个好问题”之类的占位话术。
粗排判断:当用户意图包含"批量安排/排课/把任务类排进日程"等批量调度需求时,可设置 rough_build=true后端会结合真实请求范围决定是否真正进入粗排。
二次粗排约束(强约束):
- 若上下文已出现 rough_build_done且用户未明确要求"重新粗排/从头重排",必须设置 rough_build=false。
- "移动/微调/优化/均匀化/调顺序"等请求默认视为 refine不得再次触发 rough build。
粗排后微调判断:
- 仅当 rough_build=true 时才判断 refine。
- 若用户明确提出优化目标/偏好(如"尽量均衡""周三别太满""某门课往后挪"),设 refine=true。
- 若用户只要求"先排进去/给初稿",未提出微调目标,设 refine=false。
顺序授权判断:
- reorder 仅在用户明确说明"允许打乱顺序/顺序不重要"时才为 true。
- 用户明确要求"保持顺序/不要打乱"时必须为 false。
- 若用户未明确提及顺序,一律为 false。
深度思考判断:
- thinking 仅在 route=execute 时有效。
- 当用户请求涉及复杂推理、多条件约束、需要深度分析后才能执行的操作时,设 thinking=true。
- 简单查询、单步操作设 thinking=false。
输出格式(严格两段式):
第一段(控制码,用户不可见,后端会截取):
<SMARTFLOW_ROUTE nonce="给定nonce" route="direct_reply|execute|deep_answer|plan" rough_build="false" refine="false" reorder="false" thinking="false"/>
第二段(紧接控制码之后,用户可见):
根据路由输出对应内容。
属性说明(仅 route=execute 时有效,其余路由省略这些属性):
- rough_build是否需要粗排
- refine粗排后是否需要微调
- reorder是否允许打乱顺序
- thinking后续执行阶段是否需要深度思考
合法示例:
<SMARTFLOW_ROUTE nonce="给定nonce" route="direct_reply"/>
当然可以,我先直接回答你这个问题。
<SMARTFLOW_ROUTE nonce="给定nonce" route="execute"/>
好的,我来帮你看看今天的安排。
<SMARTFLOW_ROUTE nonce="给定nonce" route="execute" rough_build="true" refine="false" reorder="false" thinking="false"/>
好的,我来帮你排课。
<SMARTFLOW_ROUTE nonce="给定nonce" route="execute" rough_build="true" refine="true" reorder="false" thinking="true"/>
好的,我来帮你排课并按你的偏好做微调。
<SMARTFLOW_ROUTE nonce="给定nonce" route="deep_answer"/>
<SMARTFLOW_ROUTE nonce="给定nonce" route="plan"/>
明白,我来帮你制定一个完整的学习计划。
禁止输出任何 JSON、markdown 代码块或额外解释。nonce 必须精确使用给定值。
----- message[1] -----
role: assistant
content:
真实对话记录:
user: "提醒我明天中午吃乡村基"
----- message[2] -----
role: assistant
content:
路由补充:
- 暂无额外流程标记。
----- message[3] -----
role: user
content:
nonce=932ef523-3e20-4595-8a09-2ff4319f0394
当前时间=2026-04-18 10:04
请基于最近真实对话和本轮输入选择最合适的路由,并严格按系统约定输出控制码。
用户本轮输入:
提醒我明天中午吃乡村基
[DEBUG] chat LLM context end phase=routing chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a round=0
2026/04/18 10:04:06 rag level=info component=store operation=ensure_collection action=search collection=smartflow_rag_chunks corpus=memory latency_ms=5 metric_type=COSINE status=already_exists store=milvus vector_dim=1024
2026/04/18 10:04:06 rag level=error component=store operation=search action=search collection=smartflow_rag_chunks corpus=memory error=unsupported milvus filter key: status error_code=RAG_ERROR filter_count=3 latency_ms=5 status=failed store=milvus top_k=10 vector_dim=1024
2026/04/18 10:04:06 rag level=error component=runtime operation=retrieve action=search corpus=memory error=unsupported milvus filter key: status error_code=RAG_ERROR latency_ms=215 query_len=33 status=failed threshold=0.55 top_k=10
2026/04/18 10:04:07 memory level=info component=read operation=retrieve dedup_drop_count=0 degraded=true final_count=5 legacy_hit_count=0 pinned_hit_count=0 query_len=33 rag_fallback_used=true read_mode=hybrid semantic_hit_count=10 success=true user_id=1
2026/04/18 10:04:07 [INFO] memory prefetch: 后台检索完成 user=1 count=5
2026/04/18 10:04:07 outbox due messages=1, start dispatch
2026/04/18 10:04:08 [GORM-Cache] Invalidated conversation history cache for user 1 conversation 6c0edfe9-2dba-4927-905b-bfdb06e19e2a
2026/04/18 10:04:09 outbox due messages=1, start dispatch
2026/04/18 10:04:09 [DEBUG] chat routing chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a route=execute needs_rough_build=false needs_refine_after_rough_build=false allow_reorder=false thinking=false has_rough_build_done=false task_class_count=0 raw=<SMARTFLOW_ROUTE nonce="932ef523-3e20-4595-8a09-2ff4319f0394" route="execute" rough_build="false" refine="false" reorder="false" thinking="false"/>
2026/04/18 10:04:10 [COMPACT:execute] token budget check: total=4060 budget=80000 over=false compactMsg1=false compactMsg2=false (msg0=3694 msg1=45 msg2=19 msg3=302)
2026/04/18 10:04:10 [DEBUG] execute LLM context begin phase=decision chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a round=1 message_count=4
----- message[0] -----
role: system
content:
你叫 SmartMate是时伴SmartMate的中文 AI 排程伙伴,面向大学生提供陪伴式日程管理与日常协助。
你擅长课表与任务安排、任务管理、学习规划和随口记,也可以正常回答日常问答、生活建议、信息整理、分析讨论等非排程问题。
你的目标是像一个越用越懂用户的伙伴一样,结合历史对话、长期记忆和当前上下文,给出贴心、清晰、可信的帮助。
你的回复应当专业、自然、有陪伴感,偶尔可以带一点轻松幽默。
如果用户的问题与日程无关,不要因为“不属于排程”就拒绝、回避或强行转到任务安排;只要不需要工具且你有把握,就直接回答。
重要约束:你无法直接写入数据库。除非系统明确告知“任务已落库成功”,否则禁止使用“已安排/已记录/已帮你记下”等完成态表述。
你是 SmartMate 的执行器,当前处于自由执行模式(无预定义 plan 步骤)。
阶段事实(强约束):
1. 若上下文给出"粗排已完成/rough_build_done",表示目标任务类已经进入 suggested/existing不是待排入状态。
2. 当前阶段目标是"微调",不是"重新粗排"。
3. 若上下文明确"当前未收到明确微调偏好/本轮先收口",应直接结束而不是继续优化循环。
4. 若用户提出了二次微调方向,本轮优先目标就是满足该方向。
你可以做什么:
1. 你可以基于用户给定的二次微调方向,对 suggested 做定向微调。
2. existing 属于已安排事实层,可用于冲突判断和参考,不作为 move/batch_move/spread_even 的目标。
3. 你可以先调用读工具补充必要事实(例如 get_overview/query_target_tasks/query_available_slots/get_task_info
4. 你可以在需要日程写操作时提出 confirmmove/swap/unplace/batch_move/spread_even。quick_note_create 不需要确认,用 action=continue若信息足够必须显式填写 priority_group若信息不足则先 ask_user。
5. 只有用户明确允许打乱顺序时,才可使用 min_context_switch。
6. 多任务处理默认使用队列链路:先 query_target_tasks(enqueue=true) 入队,再 queue_pop_head 逐项处理。
你不要做什么:
1. 不要假设任务还没排进去,然后改成逐个手动 place。
2. 不要伪造工具结果。
3. 不要重复做同类查询而没有新增结论连续两轮同类读查询后必须转入执行、ask_user或明确阻塞原因。
4. 若工具结果与已知事实明显冲突(如无写操作却从"有任务"变成"0任务"),先自我纠错并重查一次,不要直接 ask_user。
5. 不要连续两轮调用"同一读工具 + 等价 arguments";若上一轮已成功返回,下一轮必须换工具或进入 confirm。
6. 若已明确"本轮先收口",不要继续调用 query_available_slots/move 做无目标微调。
7. 若用户明确了微调方向,不要只做"局部看起来更空"的随机调整;每次改动都要能对应到该方向。
8. 若顺序策略为"保持顺序",禁止调用 min_context_switch。
9. 不要在同一轮构造大规模 batch_movebatch_move 最多 2 条,超过请走队列逐项处理。
10. 未调用 queue_pop_head 获取 current 前,不要调用 queue_apply_head_move。
11. 工具参数必须严格使用 schema 字段,禁止自造别名;例如 day_from/day_to 非法,必须改用 day_start/day_end。
12. web_search 仅在"制定学习计划需要查外部资料"时使用如考试日期、课程信息、校历政策等日程排布本身place/move/swap不需要搜索。
13. web_search 拿到 summary 后通常已够用;仅当需要页面详细内容时才调用 web_fetch。
执行规则:
1. 只输出严格 JSON不要输出 markdown不要在 JSON 外补充文本。
2. 读操作action=continue + tool_call。
3. 写操作(日程变更,如 place/move/swap/batch_move/unplace/spread_even/min_context_switchaction=confirm + tool_call。
4. quick_note_create记录任务/提醒若信息足够action=continue + tool_call并显式填写 priority_group若信息不足且无法可靠推断action=ask_user 先追问。
5. 缺关键上下文且无法通过工具补齐action=ask_user。
6. 任务完成action=done并在 goal_check 总结完成证据。
7. 流程应正式终止action=abort。
补充 JSON 约束:
1. 只输出当前 action 真正需要的字段;无关字段直接省略,不要用 ""、{}、[]、null 占位。
2. 若输出 tool_call参数字段名只能是 arguments禁止写成 parameters。
3. tool_call 只能是单个对象:{"name":"工具名","arguments":{...}},不能输出数组。
4. 只有 action=abort 时才允许输出 abort 字段;非 abort 动作不要输出 abort。
5. action=continue / ask_user / confirm 时speak 必须是非空自然语言。
可用工具(简表):
1. batch_move原子性批量移动多个任务仅 suggested最多2条全部成功才生效。若含 existing/pending 或任一冲突将整批失败回滚。
参数moves(必填,array)
返回类型string自然语言文本
返回示例批量移动完成2个任务全部成功。单次最多2条
2. get_overview获取规划窗口总览任务视角全量返回保留课程占位统计展开任务清单过滤课程明细
参数:{}
返回类型string自然语言文本
返回示例规划窗口共27天...课程占位条目34个...任务清单(全量,已过滤课程)...
3. get_task_info查询单个任务详细信息包括类别、状态、占用时段、嵌入关系。
参数task_id(必填,int)
返回类型string自然语言文本
返回示例:[35]第一章随机事件与概率 | 状态:已预排(suggested) | 占用时段第3天第5-6节
4. min_context_switch在指定任务集合内重排 suggested 任务尽量让同类任务连续以减少上下文切换。仅在用户明确允许打乱顺序时使用。task_ids 必填(兼容 task_id
参数task_id(可选,int)task_ids(必填,array)
返回类型string自然语言文本
返回示例:最少上下文切换重排完成:共处理 6 个任务,上下文切换次数 5 -> 2。
5. move将一个已预排任务仅 suggested移动到新位置。existing 属于已安排事实层,不参与 move。task_id/new_day/new_slot_start 必填。
参数new_day(必填,int)new_slot_start(必填,int)task_id(必填,int)
返回类型string自然语言文本
返回示例:已将 [35]... 从第3天第5-6节移至第5天第3-4节。
6. place将一个待安排任务预排到指定位置。自动检测可嵌入宿主。task_id/day/slot_start 必填。
参数day(必填,int)slot_start(必填,int)task_id(必填,int)
返回类型string自然语言文本
返回示例:已将 [35]... 预排到第5天第3-4节。
7. query_available_slots查询候选空位池先返回纯空位不足再补可嵌入位适合 move 前的落点筛选。
参数after_section(可选,int)allow_embed(可选,bool)before_section(可选,int)day(可选,int)day_end(可选,int)day_of_week(可选,array)day_scope(可选,string:all/workday/weekend)day_start(可选,int)duration(可选,int)exclude_sections(可选,array)limit(可选,int)section_from(可选,int)section_to(可选,int)slot_type(可选,string)slot_types(可选,array)span(可选,int)week(可选,int)week_filter(可选,array)week_from(可选,int)week_to(可选,int)
返回类型stringJSON字符串
返回示例:{"tool":"query_available_slots","count":12,"strict_count":8,"embedded_count":4,"slots":[{"day":5,"week":12,"day_of_week":3,"slot_start":1,"slot_end":2,"slot_type":"empty"}]}
8. query_range查看某天或某时段的细粒度占用详情。day 必填slot_start/slot_end 选填(不填查整天)。
参数day(必填,int)slot_end(可选,int)slot_start(可选,int)
返回类型string自然语言文本
返回示例第5天第3-6节第3节空、第4节空...
9. query_target_tasks查询候选任务集合可按 status/week/day/task_id/category 筛选;默认自动入队,供后续 queue_pop_head 逐项处理。
参数category(可选,string)day(可选,int)day_end(可选,int)day_of_week(可选,array)day_scope(可选,string:all/workday/weekend)day_start(可选,int)enqueue(可选,bool)limit(可选,int)reset_queue(可选,bool)status(可选,string:all/existing/suggested/pending)task_id(可选,int)task_ids(可选,array)task_item_id(可选,int)task_item_ids(可选,array)week(可选,int)week_filter(可选,array)week_from(可选,int)week_to(可选,int)
返回类型stringJSON字符串
返回示例:{"tool":"query_target_tasks","count":6,"status":"suggested","enqueue":true,"enqueued":6,"queue":{"pending_count":6},"items":[{"task_id":35,"name":"示例任务","status":"suggested","slots":[{"day":3,"week":12,"day_of_week":1,"slot_start":5,"slot_end":6}]}]}
10. queue_apply_head_move将当前队首任务移动到指定位置并自动出队。仅作用于 current不接受 task_id。new_day/new_slot_start 必填。
参数new_day(必填,int)new_slot_start(必填,int)
返回类型stringJSON字符串
返回示例:{"tool":"queue_apply_head_move","success":true,"task_id":35,"pending_count":4,"completed_count":2,"result":"已将 [35]... 从第3天第5-6节移至第5天第3-4节。"}
11. queue_pop_head弹出并返回当前队首任务若已有 current 则复用,保证一次只处理一个任务。
参数:{}
返回类型stringJSON字符串
返回示例:{"tool":"queue_pop_head","has_head":true,"pending_count":5,"current":{"task_id":35,"name":"示例任务","status":"suggested","slots":[{"day":3,"week":12,"day_of_week":1,"slot_start":5,"slot_end":6}]}}
12. queue_skip_head跳过当前队首任务不改日程将其标记为 skipped 并继续后续队列。
参数reason(可选,string)
返回类型stringJSON字符串
返回示例:{"tool":"queue_skip_head","success":true,"skipped_task_id":35,"pending_count":4,"skipped_count":1}
13. queue_status查看当前待处理队列状态pending/current/completed/skipped
参数:{}
返回类型stringJSON字符串
返回示例:{"tool":"queue_status","pending_count":5,"completed_count":1,"skipped_count":0,"current_task_id":35,"current_attempt":1}
14. quick_note_create记录一条任务/提醒/待办事项到用户的任务列表。支持中文相对时间如“明天下午3点”、“下周一”。title 必填。记录成功后回复时应包含一句与任务内容相关的轻松跟进话术不超过30字类似朋友间的友好调侃。
参数deadline_at(可选,string)priority_group(可选,int)title(必填,string)
返回类型string自然语言文本
返回示例:自然语言结果(成功/失败原因/关键数据摘要)。
15. spread_even在给定任务集合内做均匀化铺开先按筛选条件收集候选坑位再规划并原子落地。task_ids 必填(兼容 task_id
参数after_section(可选,int)allow_embed(可选,bool)before_section(可选,int)day(可选,int)day_end(可选,int)day_of_week(可选,array)day_scope(可选,string:all/workday/weekend)day_start(可选,int)exclude_sections(可选,array)limit(可选,int)slot_type(可选,string)slot_types(可选,array)task_id(可选,int)task_ids(必填,array)week(可选,int)week_filter(可选,array)week_from(可选,int)week_to(可选,int)
返回类型string自然语言文本
返回示例:均匀化调整完成:共处理 6 个任务,候选坑位 24 个。
16. swap交换两个已落位任务的位置。两个任务必须时长相同。task_a/task_b 必填。
参数task_a(必填,int)task_b(必填,int)
返回类型string自然语言文本
返回示例:交换完成:[35]... ↔ [36]...
17. unplace将一个已落位任务移除恢复为待安排状态。会自动清理嵌入关系。task_id 必填。
参数task_id(必填,int)
返回类型string自然语言文本
返回示例:已将 [35]... 移除,恢复为待安排状态。
18. web_fetch抓取指定 URL 的正文内容并做最小 HTML 清洗。url 必填。
参数max_chars(可选,int)url(必填,string)
返回类型stringJSON字符串
返回示例:{"tool":"web_fetch","url":"https://example.com/page","title":"页面标题","content":"正文内容...","truncated":false}
19. web_searchWeb 搜索:根据 query 返回结构化检索结果(标题/摘要/URL/来源域名/时间。query 必填。
参数domain_allow(可选,array)query(必填,string)recency_days(可选,int)top_k(可选,int)
返回类型stringJSON字符串
返回示例:{"tool":"web_search","query":"检索关键词","count":2,"items":[{"title":"搜索结果标题","url":"https://example.com/page","snippet":"摘要片段...","domain":"example.com","published_at":"2025-04-10"}]}
----- message[1] -----
role: assistant
content:
历史上下文:
对话历史:
user: "提醒我明天中午吃乡村基"
- 阶段锚点:按当前工具事实推进,不做无依据操作。
----- message[2] -----
role: assistant
content:
当轮 ReAct Loop 记录:
- 已清空(新一轮 loop 准备中)。
----- message[3] -----
role: system
content:
当前执行状态:
- 当前轮次1/60
- 当前模式:自由执行(无预定义步骤)
- 啥时候结束Loop你可以根据工具调用记录自行判断。
- 非目标:不重新粗排、不修改无关任务类。
- 参数纪律:工具参数必须严格使用 schema 字段;若返回'参数非法',需先改参再继续。
- 顺序策略:默认保持 suggested 相对顺序,禁止调用 min_context_switch。
相关记忆(仅在确有帮助时参考,不要机械复述):
以下是与当前对话相关的用户记忆,仅在自然且确实有帮助时参考,不要生硬复述。
- [约束] 用户需要智能编排任务明确要求不要早八早8点前和晚10晚10点后的安排
- [偏好] 用户表示自己喜欢听歌
- [待办线索] 用户需要提醒明天中午吃乡村基
- [待办线索] 用户希望被提醒有空时买双鞋子
- [偏好] 用户偏爱黑咖啡
本轮指令:请继续当前任务的执行阶段,严格输出 JSON。
[DEBUG] execute LLM context end phase=decision chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a round=1
2026/04/18 10:04:15 rag level=error component=store operation=search action=search collection=smartflow_rag_chunks corpus=memory error=unsupported milvus filter key: status error_code=RAG_ERROR filter_count=4 latency_ms=0 status=failed store=milvus top_k=5 vector_dim=1024
2026/04/18 10:04:15 rag level=error component=runtime operation=retrieve action=search corpus=memory error=unsupported milvus filter key: status error_code=RAG_ERROR latency_ms=115 query_len=54 status=failed threshold=0.6 top_k=5
2026/04/18 10:04:15 [WARN][去重] Milvus 语义召回失败,降级到 MySQL: user_id=1 memory_type=todo_hint topk=5 err=unsupported milvus filter key: status
2026/04/18 10:04:15 [DEBUG][去重] 语义召回候选: job_id=65 user_id=1 memory_type=todo_hint candidate_count=2
2026/04/18 10:04:15 [DEBUG][去重] 候选详情: memory_id=30 score=0.0000 content="用户需要提醒明天中午吃乡村基"
2026/04/18 10:04:15 [DEBUG][去重] 候选详情: memory_id=29 score=0.0000 content="用户希望被提醒有空时买双鞋子"
2026/04/18 10:04:17 [DEBUG][去重] LLM 比对结果: candidate_id=30 score=0.0000 relation=duplicate reason="新事实与旧记忆内容完全一致,都是提醒明天中午吃乡村基" candidate_content="用户需要提醒明天中午吃乡村基"
2026/04/18 10:04:17 [DEBUG] execute LLM 响应 chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a round=1 action=continue speak_len=57 raw_len=301 raw_preview=```json
{
"action": "continue",
"speak": "好的,帮你记下明天中午吃乡村基的提醒。",
"tool_call": {
"name": "quick_note_create",
"arguments": {
"title": "明天中午吃乡村基",
"deadline_at": "明天中午12点",
2026/04/18 10:04:18 [DEBUG] execute tool chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a round=1 tool=quick_note_create args={"_user_id":1,"deadline_at":"明天中午12点","priority_group":2,"title":"明天中午吃乡村基"} before=tasks=117 pending=56 suggested=0 existing=61 task_item_with_slot=0 event_with_slot=52 after=tasks=117 pending=56 suggested=0 existing=61 task_item_with_slot=0 event_with_slot=52 result_preview={"task_id":56,"title":"明天中午吃乡村基","priority_label":"重要不紧急","deadline_at":"2026-04-19 12:00","message":"已记录:明天中午吃乡村基(重要不紧急,截止 2026-04-19 12:00。回复时请用轻松友好的语气加一句与任务内容相关的俏皮话不超过30字。"}
2026/04/18 10:04:18 [GORM-Cache] Invalidated task list cache for user 1
2026/04/18 10:04:18 [COMPACT:execute] token budget check: total=4224 budget=80000 over=false compactMsg1=false compactMsg2=false (msg0=3694 msg1=66 msg2=162 msg3=302)
2026/04/18 10:04:18 [DEBUG] execute LLM context begin phase=decision chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a round=2 message_count=4
----- message[0] -----
role: system
content:
你叫 SmartMate是时伴SmartMate的中文 AI 排程伙伴,面向大学生提供陪伴式日程管理与日常协助。
你擅长课表与任务安排、任务管理、学习规划和随口记,也可以正常回答日常问答、生活建议、信息整理、分析讨论等非排程问题。
你的目标是像一个越用越懂用户的伙伴一样,结合历史对话、长期记忆和当前上下文,给出贴心、清晰、可信的帮助。
你的回复应当专业、自然、有陪伴感,偶尔可以带一点轻松幽默。
如果用户的问题与日程无关,不要因为“不属于排程”就拒绝、回避或强行转到任务安排;只要不需要工具且你有把握,就直接回答。
重要约束:你无法直接写入数据库。除非系统明确告知“任务已落库成功”,否则禁止使用“已安排/已记录/已帮你记下”等完成态表述。
你是 SmartMate 的执行器,当前处于自由执行模式(无预定义 plan 步骤)。
阶段事实(强约束):
1. 若上下文给出"粗排已完成/rough_build_done",表示目标任务类已经进入 suggested/existing不是待排入状态。
2. 当前阶段目标是"微调",不是"重新粗排"。
3. 若上下文明确"当前未收到明确微调偏好/本轮先收口",应直接结束而不是继续优化循环。
4. 若用户提出了二次微调方向,本轮优先目标就是满足该方向。
你可以做什么:
1. 你可以基于用户给定的二次微调方向,对 suggested 做定向微调。
2. existing 属于已安排事实层,可用于冲突判断和参考,不作为 move/batch_move/spread_even 的目标。
3. 你可以先调用读工具补充必要事实(例如 get_overview/query_target_tasks/query_available_slots/get_task_info
4. 你可以在需要日程写操作时提出 confirmmove/swap/unplace/batch_move/spread_even。quick_note_create 不需要确认,用 action=continue若信息足够必须显式填写 priority_group若信息不足则先 ask_user。
5. 只有用户明确允许打乱顺序时,才可使用 min_context_switch。
6. 多任务处理默认使用队列链路:先 query_target_tasks(enqueue=true) 入队,再 queue_pop_head 逐项处理。
你不要做什么:
1. 不要假设任务还没排进去,然后改成逐个手动 place。
2. 不要伪造工具结果。
3. 不要重复做同类查询而没有新增结论连续两轮同类读查询后必须转入执行、ask_user或明确阻塞原因。
4. 若工具结果与已知事实明显冲突(如无写操作却从"有任务"变成"0任务"),先自我纠错并重查一次,不要直接 ask_user。
5. 不要连续两轮调用"同一读工具 + 等价 arguments";若上一轮已成功返回,下一轮必须换工具或进入 confirm。
6. 若已明确"本轮先收口",不要继续调用 query_available_slots/move 做无目标微调。
7. 若用户明确了微调方向,不要只做"局部看起来更空"的随机调整;每次改动都要能对应到该方向。
8. 若顺序策略为"保持顺序",禁止调用 min_context_switch。
9. 不要在同一轮构造大规模 batch_movebatch_move 最多 2 条,超过请走队列逐项处理。
10. 未调用 queue_pop_head 获取 current 前,不要调用 queue_apply_head_move。
11. 工具参数必须严格使用 schema 字段,禁止自造别名;例如 day_from/day_to 非法,必须改用 day_start/day_end。
12. web_search 仅在"制定学习计划需要查外部资料"时使用如考试日期、课程信息、校历政策等日程排布本身place/move/swap不需要搜索。
13. web_search 拿到 summary 后通常已够用;仅当需要页面详细内容时才调用 web_fetch。
执行规则:
1. 只输出严格 JSON不要输出 markdown不要在 JSON 外补充文本。
2. 读操作action=continue + tool_call。
3. 写操作(日程变更,如 place/move/swap/batch_move/unplace/spread_even/min_context_switchaction=confirm + tool_call。
4. quick_note_create记录任务/提醒若信息足够action=continue + tool_call并显式填写 priority_group若信息不足且无法可靠推断action=ask_user 先追问。
5. 缺关键上下文且无法通过工具补齐action=ask_user。
6. 任务完成action=done并在 goal_check 总结完成证据。
7. 流程应正式终止action=abort。
补充 JSON 约束:
1. 只输出当前 action 真正需要的字段;无关字段直接省略,不要用 ""、{}、[]、null 占位。
2. 若输出 tool_call参数字段名只能是 arguments禁止写成 parameters。
3. tool_call 只能是单个对象:{"name":"工具名","arguments":{...}},不能输出数组。
4. 只有 action=abort 时才允许输出 abort 字段;非 abort 动作不要输出 abort。
5. action=continue / ask_user / confirm 时speak 必须是非空自然语言。
可用工具(简表):
1. batch_move原子性批量移动多个任务仅 suggested最多2条全部成功才生效。若含 existing/pending 或任一冲突将整批失败回滚。
参数moves(必填,array)
返回类型string自然语言文本
返回示例批量移动完成2个任务全部成功。单次最多2条
2. get_overview获取规划窗口总览任务视角全量返回保留课程占位统计展开任务清单过滤课程明细
参数:{}
返回类型string自然语言文本
返回示例规划窗口共27天...课程占位条目34个...任务清单(全量,已过滤课程)...
3. get_task_info查询单个任务详细信息包括类别、状态、占用时段、嵌入关系。
参数task_id(必填,int)
返回类型string自然语言文本
返回示例:[35]第一章随机事件与概率 | 状态:已预排(suggested) | 占用时段第3天第5-6节
4. min_context_switch在指定任务集合内重排 suggested 任务尽量让同类任务连续以减少上下文切换。仅在用户明确允许打乱顺序时使用。task_ids 必填(兼容 task_id
参数task_id(可选,int)task_ids(必填,array)
返回类型string自然语言文本
返回示例:最少上下文切换重排完成:共处理 6 个任务,上下文切换次数 5 -> 2。
5. move将一个已预排任务仅 suggested移动到新位置。existing 属于已安排事实层,不参与 move。task_id/new_day/new_slot_start 必填。
参数new_day(必填,int)new_slot_start(必填,int)task_id(必填,int)
返回类型string自然语言文本
返回示例:已将 [35]... 从第3天第5-6节移至第5天第3-4节。
6. place将一个待安排任务预排到指定位置。自动检测可嵌入宿主。task_id/day/slot_start 必填。
参数day(必填,int)slot_start(必填,int)task_id(必填,int)
返回类型string自然语言文本
返回示例:已将 [35]... 预排到第5天第3-4节。
7. query_available_slots查询候选空位池先返回纯空位不足再补可嵌入位适合 move 前的落点筛选。
参数after_section(可选,int)allow_embed(可选,bool)before_section(可选,int)day(可选,int)day_end(可选,int)day_of_week(可选,array)day_scope(可选,string:all/workday/weekend)day_start(可选,int)duration(可选,int)exclude_sections(可选,array)limit(可选,int)section_from(可选,int)section_to(可选,int)slot_type(可选,string)slot_types(可选,array)span(可选,int)week(可选,int)week_filter(可选,array)week_from(可选,int)week_to(可选,int)
返回类型stringJSON字符串
返回示例:{"tool":"query_available_slots","count":12,"strict_count":8,"embedded_count":4,"slots":[{"day":5,"week":12,"day_of_week":3,"slot_start":1,"slot_end":2,"slot_type":"empty"}]}
8. query_range查看某天或某时段的细粒度占用详情。day 必填slot_start/slot_end 选填(不填查整天)。
参数day(必填,int)slot_end(可选,int)slot_start(可选,int)
返回类型string自然语言文本
返回示例第5天第3-6节第3节空、第4节空...
9. query_target_tasks查询候选任务集合可按 status/week/day/task_id/category 筛选;默认自动入队,供后续 queue_pop_head 逐项处理。
参数category(可选,string)day(可选,int)day_end(可选,int)day_of_week(可选,array)day_scope(可选,string:all/workday/weekend)day_start(可选,int)enqueue(可选,bool)limit(可选,int)reset_queue(可选,bool)status(可选,string:all/existing/suggested/pending)task_id(可选,int)task_ids(可选,array)task_item_id(可选,int)task_item_ids(可选,array)week(可选,int)week_filter(可选,array)week_from(可选,int)week_to(可选,int)
返回类型stringJSON字符串
返回示例:{"tool":"query_target_tasks","count":6,"status":"suggested","enqueue":true,"enqueued":6,"queue":{"pending_count":6},"items":[{"task_id":35,"name":"示例任务","status":"suggested","slots":[{"day":3,"week":12,"day_of_week":1,"slot_start":5,"slot_end":6}]}]}
10. queue_apply_head_move将当前队首任务移动到指定位置并自动出队。仅作用于 current不接受 task_id。new_day/new_slot_start 必填。
参数new_day(必填,int)new_slot_start(必填,int)
返回类型stringJSON字符串
返回示例:{"tool":"queue_apply_head_move","success":true,"task_id":35,"pending_count":4,"completed_count":2,"result":"已将 [35]... 从第3天第5-6节移至第5天第3-4节。"}
11. queue_pop_head弹出并返回当前队首任务若已有 current 则复用,保证一次只处理一个任务。
参数:{}
返回类型stringJSON字符串
返回示例:{"tool":"queue_pop_head","has_head":true,"pending_count":5,"current":{"task_id":35,"name":"示例任务","status":"suggested","slots":[{"day":3,"week":12,"day_of_week":1,"slot_start":5,"slot_end":6}]}}
12. queue_skip_head跳过当前队首任务不改日程将其标记为 skipped 并继续后续队列。
参数reason(可选,string)
返回类型stringJSON字符串
返回示例:{"tool":"queue_skip_head","success":true,"skipped_task_id":35,"pending_count":4,"skipped_count":1}
13. queue_status查看当前待处理队列状态pending/current/completed/skipped
参数:{}
返回类型stringJSON字符串
返回示例:{"tool":"queue_status","pending_count":5,"completed_count":1,"skipped_count":0,"current_task_id":35,"current_attempt":1}
14. quick_note_create记录一条任务/提醒/待办事项到用户的任务列表。支持中文相对时间如“明天下午3点”、“下周一”。title 必填。记录成功后回复时应包含一句与任务内容相关的轻松跟进话术不超过30字类似朋友间的友好调侃。
参数deadline_at(可选,string)priority_group(可选,int)title(必填,string)
返回类型string自然语言文本
返回示例:自然语言结果(成功/失败原因/关键数据摘要)。
15. spread_even在给定任务集合内做均匀化铺开先按筛选条件收集候选坑位再规划并原子落地。task_ids 必填(兼容 task_id
参数after_section(可选,int)allow_embed(可选,bool)before_section(可选,int)day(可选,int)day_end(可选,int)day_of_week(可选,array)day_scope(可选,string:all/workday/weekend)day_start(可选,int)exclude_sections(可选,array)limit(可选,int)slot_type(可选,string)slot_types(可选,array)task_id(可选,int)task_ids(必填,array)week(可选,int)week_filter(可选,array)week_from(可选,int)week_to(可选,int)
返回类型string自然语言文本
返回示例:均匀化调整完成:共处理 6 个任务,候选坑位 24 个。
16. swap交换两个已落位任务的位置。两个任务必须时长相同。task_a/task_b 必填。
参数task_a(必填,int)task_b(必填,int)
返回类型string自然语言文本
返回示例:交换完成:[35]... ↔ [36]...
17. unplace将一个已落位任务移除恢复为待安排状态。会自动清理嵌入关系。task_id 必填。
参数task_id(必填,int)
返回类型string自然语言文本
返回示例:已将 [35]... 移除,恢复为待安排状态。
18. web_fetch抓取指定 URL 的正文内容并做最小 HTML 清洗。url 必填。
参数max_chars(可选,int)url(必填,string)
返回类型stringJSON字符串
返回示例:{"tool":"web_fetch","url":"https://example.com/page","title":"页面标题","content":"正文内容...","truncated":false}
19. web_searchWeb 搜索:根据 query 返回结构化检索结果(标题/摘要/URL/来源域名/时间。query 必填。
参数domain_allow(可选,array)query(必填,string)recency_days(可选,int)top_k(可选,int)
返回类型stringJSON字符串
返回示例:{"tool":"web_search","query":"检索关键词","count":2,"items":[{"title":"搜索结果标题","url":"https://example.com/page","snippet":"摘要片段...","domain":"example.com","published_at":"2025-04-10"}]}
----- message[1] -----
role: assistant
content:
历史上下文:
对话历史:
user: "提醒我明天中午吃乡村基"
assistant: "好的,帮你记下明天中午吃乡村基的提醒。"
- 阶段锚点:按当前工具事实推进,不做无依据操作。
----- message[2] -----
role: assistant
content:
当轮 ReAct Loop 记录:
1) thought/reason好的帮你记下明天中午吃乡村基的提醒。
tool_callquick_note_create({"_user_id":1,"deadline_at":"明天中午12点","priority_group":2,"title":"明天中午吃乡村基"})
observation{"task_id":56,"title":"明天中午吃乡村基","priority_label":"重要不紧急","deadline_at":"2026-04-19 12:00","message":"已记录:明天中午吃乡村基(重要不紧急,截止 2026-04-19 12:00。回复时请用轻松友好的语气加一句与任务内容相关的俏皮话不超过30字。"}
----- message[3] -----
role: system
content:
当前执行状态:
- 当前轮次2/60
- 当前模式:自由执行(无预定义步骤)
- 啥时候结束Loop你可以根据工具调用记录自行判断。
- 非目标:不重新粗排、不修改无关任务类。
- 参数纪律:工具参数必须严格使用 schema 字段;若返回'参数非法',需先改参再继续。
- 顺序策略:默认保持 suggested 相对顺序,禁止调用 min_context_switch。
相关记忆(仅在确有帮助时参考,不要机械复述):
以下是与当前对话相关的用户记忆,仅在自然且确实有帮助时参考,不要生硬复述。
- [约束] 用户需要智能编排任务明确要求不要早八早8点前和晚10晚10点后的安排
- [偏好] 用户表示自己喜欢听歌
- [待办线索] 用户需要提醒明天中午吃乡村基
- [待办线索] 用户希望被提醒有空时买双鞋子
- [偏好] 用户偏爱黑咖啡
本轮指令:请继续当前任务的执行阶段,严格输出 JSON。
[DEBUG] execute LLM context end phase=decision chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a round=2
2026/04/18 10:04:18 outbox due messages=1, start dispatch
2026/04/18 10:04:19 [GORM-Cache] Invalidated conversation history cache for user 1 conversation 6c0edfe9-2dba-4927-905b-bfdb06e19e2a
2026/04/18 10:04:19 [DEBUG][去重] LLM 比对结果: candidate_id=29 score=0.0000 relation=unrelated reason="新事实是关于明天中午吃饭的提醒,旧记忆是关于买鞋子的提醒,两者属于不同待办事项" candidate_content="用户希望被提醒有空时买双鞋子"
2026/04/18 10:04:19 [DEBUG][去重] 汇总决策: job_id=65 action=NONE target_id=0 reason="存在完全重复的旧记忆,跳过写入"
2026/04/18 10:04:19 memory level=info component=write operation=decision candidate_count=2 conversation_id=6c0edfe9-2dba-4927-905b-bfdb06e19e2a fact_type=todo_hint fallback_mode=rag_to_mysql final_action=NONE job_id=65 success=true user_id=1
2026/04/18 10:04:19 [去重] 决策流程完成: job_id=65 user_id=1 新增=0 更新=0 删除=0 跳过=1
2026/04/18 10:04:19 memory level=info component=write operation=job conversation_id=6c0edfe9-2dba-4927-905b-bfdb06e19e2a job_id=65 status=success success=true user_id=1
[GIN] 2026/04/18 - 10:04:25 | 200 | 19.2626896s | 127.0.0.1 | POST "/api/v1/agent/chat"
2026/04/18 10:04:25 [ERROR] newAgent graph 执行失败 trace=a35fc40f-261f-4d51-93cd-783f256b9902 chat=6c0edfe9-2dba-4927-905b-bfdb06e19e2a: [NodeRunError] 执行阶段模型调用失败: failed to create chat completion: context canceled
------------------------
node path: [execute]
2026/04/18 10:04:25 错误通道已满,丢弃错误: context canceled
[GIN] 2026/04/18 - 10:04:25 | 200 | 50.3305ms | 127.0.0.1 | GET "/api/v1/agent/conversation-list?page=1&page_size=12&limit=12&status=active"
[GIN] 2026/04/18 - 10:04:26 | 200 | 49.4404ms | 127.0.0.1 | GET "/api/v1/agent/conversation-history?conversation_id=6c0edfe9-2dba-4927-905b-bfdb06e19e2a"
[GIN] 2026/04/18 - 10:04:26 | 200 | 1.9621ms | 127.0.0.1 | GET "/api/v1/agent/context-stats?conversation_id=6c0edfe9-2dba-4927-905b-bfdb06e19e2a"
[GIN] 2026/04/18 - 10:04:26 | 200 | 28.7049ms | 127.0.0.1 | GET "/api/v1/agent/conversation-meta?conversation_id=6c0edfe9-2dba-4927-905b-bfdb06e19e2a"

View File

@@ -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() 算法
- 结果写入 ScheduleStatepending 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 行)。
---
#### P1TaskClass 约束元数据暴露给 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()` 函数。
---
#### P3LLM 主动追问能力增强
**问题**:当前 Chat 节点主要做"接收用户消息 + confirm resume",缺少"LLM 主动收集排课需求"的能力。
**改造内容**
- Chat 节点的 prompt 增强:
- 引导 LLM 在信息不足时主动追问
- 追问内容:考试科目、复习偏好、时段排除、强度偏好
- 追问方式:通过 `ask_user` action 或直接在 speak 中提问
- 可能需要新增 ConversationContext 的"收集到的需求"字段
- 收集到的需求在 Plan 节点中被使用
---
#### P4LLM 创建任务类工具(锦上添花)
**问题**:用户说"帮我安排复习",但系统里没有对应的 TaskClassLLM 无法创建。
**改造内容**
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 |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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/taskclasspacks 仅允许 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
}

View File

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

View File

@@ -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 处理复杂问答:关闭路由流 → 第二次流式调用。
//
// 步骤说明:

View File

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

View File

@@ -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 节点推送,这里只需推送状态通知并持久化。

View File

@@ -1,462 +0,0 @@
package newagentnode
import (
"context"
"fmt"
"log"
"sort"
"strings"
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
)
const (
orderGuardStageName = "order_guard"
orderGuardStatusBlock = "order_guard.status"
)
type suggestedOrderItem struct {
StateID int
Day int
SlotStart int
SlotEnd int
Slots []schedule.TaskSlot
}
type orderRestoreResult struct {
Restored bool
Changed int
Detail string
}
// RunOrderGuardNode 负责在收口前校验 suggested 任务相对顺序是否被打乱。
//
// 职责边界:
// 1. 只做“相对顺序守卫”这一件事,不负责执行调度工具,也不负责写库;
// 2. 仅当 AllowReorder=false 时生效,用户明确授权可打乱顺序时直接放行;
// 3. 校验失败时优先“自动复原相对顺序”,由 Deliver 节点继续交付,不再直接终止。
func RunOrderGuardNode(ctx context.Context, st *newagentmodel.AgentGraphState) error {
if st == nil {
return fmt.Errorf("order_guard node: state is nil")
}
flowState := st.EnsureFlowState()
if flowState == nil {
return fmt.Errorf("order_guard node: flow state is nil")
}
// 1. 用户明确授权可打乱顺序时,顺序守卫节点直接放行。
if flowState.AllowReorder {
return nil
}
// 2. 读取当前 ScheduleState提取 suggested 任务的“时间顺序快照”。
scheduleState, err := st.EnsureScheduleState(ctx)
if err != nil {
return fmt.Errorf("order_guard node: load schedule state failed: %w", err)
}
if scheduleState == nil {
return nil
}
currentOrder := buildSuggestedOrderSnapshot(scheduleState)
// 3. 基线为空时,仅初始化基线并放行,避免第一次进入守卫就误判。
if len(flowState.SuggestedOrderBaseline) == 0 {
flowState.SuggestedOrderBaseline = append([]int(nil), currentOrder...)
_ = st.EnsureChunkEmitter().EmitStatus(
orderGuardStatusBlock,
orderGuardStageName,
"order_guard_initialized",
"已记录本轮建议任务顺序基线,继续交付当前结果。",
false,
)
return nil
}
// 4. 基线存在时做逆序检测;发现逆序后优先自动复原,而不是直接中止。
violated, detail := detectRelativeOrderViolation(flowState.SuggestedOrderBaseline, currentOrder)
if !violated {
_ = st.EnsureChunkEmitter().EmitStatus(
orderGuardStatusBlock,
orderGuardStageName,
"order_guard_passed",
"顺序守卫校验通过,保持原有相对顺序。",
false,
)
return nil
}
// 4.1 违序后进入自动复原:
// 1) 复用“当前坑位集合”,按 baseline 相对顺序回填任务;
// 2) 成功则继续 completed 路径,保证预览可写入;
// 3) 若复原条件不满足,保守放行并输出诊断,避免再次把整轮流程打成 aborted。
restore := restoreSuggestedOrderByBaseline(scheduleState, flowState.SuggestedOrderBaseline)
if restore.Restored {
_ = st.EnsureChunkEmitter().EmitStatus(
orderGuardStatusBlock,
orderGuardStageName,
"order_guard_restored",
fmt.Sprintf("检测到建议任务顺序被打乱,已自动复原(调整 %d 个任务)。", restore.Changed),
false,
)
return nil
}
_ = st.EnsureChunkEmitter().EmitStatus(
orderGuardStatusBlock,
orderGuardStageName,
"order_guard_restore_skipped",
"检测到顺序异常,但本次未执行自动复原,已继续交付当前结果。详情见日志。",
false,
)
log.Printf(
"[WARN] order_guard restore skipped chat=%s baseline=%v current=%v detail=%s restore_detail=%s",
flowState.ConversationID,
flowState.SuggestedOrderBaseline,
currentOrder,
detail,
restore.Detail,
)
return nil
}
// buildSuggestedOrderSnapshot 生成 suggested 任务的相对顺序快照(按时间坐标排序)。
//
// 说明:
// 1. 这里只关心 suggested 任务,因为顺序守卫目标是约束“本轮建议层”的相对次序;
// 2. 多 slot 任务取“最早 slot”作为排序锚点保证排序键稳定
// 3. 返回值是 state_id 列表,便于写入 CommonState 做跨节点持久化。
func buildSuggestedOrderSnapshot(state *schedule.ScheduleState) []int {
items := buildSuggestedOrderItems(state)
order := make([]int, 0, len(items))
for _, item := range items {
order = append(order, item.StateID)
}
return order
}
// buildSuggestedOrderItems 生成 suggested 任务的排序明细。
//
// 职责边界:
// 1. 统一封装顺序守卫和自动复原都需要的排序素材,避免两处逻辑口径漂移;
// 2. 排序键保持与历史实现一致day -> slot_start -> slot_end -> state_id
// 3. 每项附带完整 slots 快照,供“坑位复用式复原”直接使用。
func buildSuggestedOrderItems(state *schedule.ScheduleState) []suggestedOrderItem {
if state == nil || len(state.Tasks) == 0 {
return nil
}
items := make([]suggestedOrderItem, 0, len(state.Tasks))
for i := range state.Tasks {
task := state.Tasks[i]
if !schedule.IsSuggestedTask(task) || len(task.Slots) == 0 {
continue
}
day, slotStart, slotEnd := earliestTaskSlot(task.Slots)
items = append(items, suggestedOrderItem{
StateID: task.StateID,
Day: day,
SlotStart: slotStart,
SlotEnd: slotEnd,
Slots: cloneTaskSlots(task.Slots),
})
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].Day != items[j].Day {
return items[i].Day < items[j].Day
}
if items[i].SlotStart != items[j].SlotStart {
return items[i].SlotStart < items[j].SlotStart
}
if items[i].SlotEnd != items[j].SlotEnd {
return items[i].SlotEnd < items[j].SlotEnd
}
return items[i].StateID < items[j].StateID
})
return items
}
func earliestTaskSlot(slots []schedule.TaskSlot) (day int, slotStart int, slotEnd int) {
if len(slots) == 0 {
return 0, 0, 0
}
best := slots[0]
for i := 1; i < len(slots); i++ {
current := slots[i]
if current.Day < best.Day {
best = current
continue
}
if current.Day == best.Day && current.SlotStart < best.SlotStart {
best = current
continue
}
if current.Day == best.Day && current.SlotStart == best.SlotStart && current.SlotEnd < best.SlotEnd {
best = current
}
}
return best.Day, best.SlotStart, best.SlotEnd
}
// detectRelativeOrderViolation 检查 current 是否破坏 baseline 的相对顺序。
//
// 规则:
// 1. 仅比较 baseline 与 current 的交集任务,避免新增/删除任务引发误报;
// 2. 一旦出现 rank 逆序即判定为 violation
// 3. detail 只用于内部排查,不直接给用户。
func detectRelativeOrderViolation(baseline []int, current []int) (bool, string) {
if len(baseline) == 0 || len(current) == 0 {
return false, ""
}
rankByID := make(map[int]int, len(baseline))
for idx, id := range baseline {
rankByID[id] = idx
}
filtered := make([]int, 0, len(current))
for _, id := range current {
if _, ok := rankByID[id]; ok {
filtered = append(filtered, id)
}
}
if len(filtered) < 2 {
return false, ""
}
prevID := filtered[0]
prevRank := rankByID[prevID]
for i := 1; i < len(filtered); i++ {
id := filtered[i]
rank := rankByID[id]
if rank < prevRank {
return true, strings.TrimSpace(fmt.Sprintf(
"reverse pair detected: prev_id=%d prev_rank=%d current_id=%d current_rank=%d",
prevID, prevRank, id, rank,
))
}
prevID = id
prevRank = rank
}
return false, ""
}
// restoreSuggestedOrderByBaseline 在“默认不允许打乱顺序”场景下自动复原 suggested 相对顺序。
//
// 步骤化说明:
// 1. 先提取 baseline 与 current 的交集任务,确保只修复本轮可比对对象;
// 2. 复用 current 的“坑位序列”(时段集合),按 baseline 顺序重新回填任务;
// 3. 回填前校验时长兼容,避免把长任务塞进短坑位;
// 4. 回填后再次校验顺序;若失败则回滚,保证状态不会半成功。
func restoreSuggestedOrderByBaseline(state *schedule.ScheduleState, baseline []int) orderRestoreResult {
if state == nil {
return orderRestoreResult{Restored: false, Detail: "schedule_state=nil"}
}
if len(baseline) == 0 {
return orderRestoreResult{Restored: true}
}
items := buildSuggestedOrderItems(state)
if len(items) < 2 {
return orderRestoreResult{Restored: true}
}
itemByID := make(map[int]suggestedOrderItem, len(items))
currentInScope := make([]int, 0, len(items))
for _, item := range items {
itemByID[item.StateID] = item
}
for _, item := range items {
if _, ok := itemByID[item.StateID]; ok {
currentInScope = append(currentInScope, item.StateID)
}
}
baselineInScope := make([]int, 0, len(baseline))
for _, id := range baseline {
if _, ok := itemByID[id]; ok {
baselineInScope = append(baselineInScope, id)
}
}
if len(baselineInScope) < 2 {
return orderRestoreResult{Restored: true}
}
// currentInScope 只保留 baseline 交集,保证两边长度一致且语义可比。
baselineSet := make(map[int]struct{}, len(baselineInScope))
for _, id := range baselineInScope {
baselineSet[id] = struct{}{}
}
filteredCurrent := make([]int, 0, len(currentInScope))
for _, id := range currentInScope {
if _, ok := baselineSet[id]; ok {
filteredCurrent = append(filteredCurrent, id)
}
}
if sameIDOrder(filteredCurrent, baselineInScope) {
return orderRestoreResult{Restored: true}
}
if len(filteredCurrent) != len(baselineInScope) {
return orderRestoreResult{
Restored: false,
Detail: fmt.Sprintf("size_mismatch baseline=%d current=%d", len(baselineInScope), len(filteredCurrent)),
}
}
// 1. 先构建“当前坑位序列”。
slotPool := make([][]schedule.TaskSlot, 0, len(filteredCurrent))
for _, currentID := range filteredCurrent {
item, ok := itemByID[currentID]
if !ok {
return orderRestoreResult{
Restored: false,
Detail: fmt.Sprintf("current_id_missing id=%d", currentID),
}
}
slotPool = append(slotPool, cloneTaskSlots(item.Slots))
}
// 2. 回填前做兼容性校验:默认要求“目标任务时长 == 坑位时长”。
for i, targetID := range baselineInScope {
targetTask := state.TaskByStateID(targetID)
if targetTask == nil {
return orderRestoreResult{
Restored: false,
Detail: fmt.Sprintf("target_task_missing id=%d", targetID),
}
}
if !isSlotsCompatibleWithTask(*targetTask, slotPool[i]) {
return orderRestoreResult{
Restored: false,
Detail: fmt.Sprintf(
"slot_incompatible target=%d expected_duration=%d slot_duration=%d expected_segments=%d slot_segments=%d",
targetID,
expectedTaskDuration(*targetTask),
totalSlotDuration(slotPool[i]),
len(targetTask.Slots),
len(slotPool[i]),
),
}
}
}
// 3. 执行回填,并在失败时支持回滚。
beforeSlots := make(map[int][]schedule.TaskSlot, len(baselineInScope))
changed := 0
for i, targetID := range baselineInScope {
task := state.TaskByStateID(targetID)
if task == nil {
continue
}
beforeSlots[targetID] = cloneTaskSlots(task.Slots)
targetSlots := cloneTaskSlots(slotPool[i])
if !equalTaskSlots(task.Slots, targetSlots) {
task.Slots = targetSlots
changed++
}
}
afterOrder := buildSuggestedOrderSnapshot(state)
afterFiltered := make([]int, 0, len(afterOrder))
for _, id := range afterOrder {
if _, ok := baselineSet[id]; ok {
afterFiltered = append(afterFiltered, id)
}
}
if !sameIDOrder(afterFiltered, baselineInScope) {
// 回滚,避免保留半成功状态。
for _, targetID := range baselineInScope {
task := state.TaskByStateID(targetID)
if task == nil {
continue
}
task.Slots = cloneTaskSlots(beforeSlots[targetID])
}
return orderRestoreResult{
Restored: false,
Detail: fmt.Sprintf(
"restore_verify_failed expected=%v actual=%v",
baselineInScope, afterFiltered,
),
}
}
return orderRestoreResult{
Restored: true,
Changed: changed,
}
}
func sameIDOrder(left, right []int) bool {
if len(left) != len(right) {
return false
}
for i := range left {
if left[i] != right[i] {
return false
}
}
return true
}
func cloneTaskSlots(slots []schedule.TaskSlot) []schedule.TaskSlot {
if len(slots) == 0 {
return nil
}
copied := make([]schedule.TaskSlot, len(slots))
copy(copied, slots)
return copied
}
func equalTaskSlots(left, right []schedule.TaskSlot) bool {
if len(left) != len(right) {
return false
}
for i := range left {
if left[i].Day != right[i].Day {
return false
}
if left[i].SlotStart != right[i].SlotStart {
return false
}
if left[i].SlotEnd != right[i].SlotEnd {
return false
}
}
return true
}
func expectedTaskDuration(task schedule.ScheduleTask) int {
if task.Duration > 0 {
return task.Duration
}
if len(task.Slots) > 0 {
return totalSlotDuration(task.Slots)
}
return 0
}
func totalSlotDuration(slots []schedule.TaskSlot) int {
total := 0
for _, slot := range slots {
total += slot.SlotEnd - slot.SlotStart + 1
}
return total
}
func isSlotsCompatibleWithTask(task schedule.ScheduleTask, slots []schedule.TaskSlot) bool {
if len(slots) == 0 {
return false
}
expectedDuration := expectedTaskDuration(task)
if expectedDuration > 0 && expectedDuration != totalSlotDuration(slots) {
return false
}
// 兼容策略:当前任务已有多段落位时,要求目标坑位段数一致,避免跨段语义被破坏。
if len(task.Slots) > 0 && len(task.Slots) != len(slots) {
return false
}
return true
}

View File

@@ -89,7 +89,10 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
messages,
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

View File

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

View File

@@ -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 满足基本可用,再保障 BC 按剩余资源推进。
### 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不允许静默推断。
- 当前必要点清单:
- 时间窗(至少明确 endstart 可按策略补齐);
- 强度方向(均匀/冲刺);
- 容错偏好(高容错/平衡/低容错);
- 禁排时段(若用户表达了禁忌但未结构化)。
### 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_draftWebSearch增强 | 共创模式 | 从 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-029Phase 1出场标准窗口数、D-030Phase 1.5与Phase 1时序、D-031Phase 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 连续观测-调整-复盘-收口的优化过程 |
| 收口 | 达到阈值后停止迭代并输出最终方案 |
| 主问题域 | 单轮优化聚焦的首要问题类型 |

View File

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

View File

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

View File

@@ -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_switchaction=confirm + tool_call。
4. 缺关键上下文且无法通过工具补齐action=ask_user。
5. 仅当当前步骤完成时输出 action=next_plan并在 goal_check 对照 done_when 给出证据。
6. 仅当整体任务完成时输出 action=done并在 goal_check 总结完成证据。
7. 流程应正式终止时输出 action=abort。`
const executeSystemPromptReAct = `
你是 SmartMate 的执行器,当前处于自由执行模式(无预定义 plan 步骤)。
阶段事实(强约束):
1. 若上下文给出"粗排已完成/rough_build_done",表示目标任务类已经进入 suggested/existing不是待排入状态。
2. 当前阶段目标是"微调",不是"重新粗排"。
3. 若上下文明确"当前未收到明确微调偏好/本轮先收口",应直接结束而不是继续优化循环。
4. 若用户提出了二次微调方向,本轮优先目标就是满足该方向。
你可以做什么:
1. 你可以基于用户给定的二次微调方向,对 suggested 做定向微调。
2. existing 属于已安排事实层,可用于冲突判断和参考,不作为 move/batch_move/spread_even 的目标。
3. 你可以先调用读工具补充必要事实(例如 get_overview/query_target_tasks/query_available_slots/get_task_info
4. 你可以在需要日程写操作时提出 confirmmove/swap/unplace/batch_move/spread_even
5. 只有用户明确允许打乱顺序时,才可使用 min_context_switch。
6. 多任务处理默认使用队列链路:先 query_target_tasks(enqueue=true) 入队,再 queue_pop_head 逐项处理。
你不要做什么:
1. 不要假设任务还没排进去,然后改成逐个手动 place。
2. 不要伪造工具结果。
3. 不要重复做同类查询而没有新增结论连续两轮同类读查询后必须转入执行、ask_user或明确阻塞原因。
4. 若工具结果与已知事实明显冲突(如无写操作却从"有任务"变成"0任务"),先自我纠错并重查一次,不要直接 ask_user。
5. 不要连续两轮调用"同一读工具 + 等价 arguments";若上一轮已成功返回,下一轮必须换工具或进入 confirm。
6. 若已明确"本轮先收口",不要继续调用 query_available_slots/move 做无目标微调。
7. 若用户明确了微调方向,不要只做"局部看起来更空"的随机调整;每次改动都要能对应到该方向。
8. 若顺序策略为"保持顺序",禁止调用 min_context_switch。
9. 不要在同一轮构造大规模 batch_movebatch_move 最多 2 条,超过请走队列逐项处理。
10. 未调用 queue_pop_head 获取 current 前,不要调用 queue_apply_head_move。
11. 工具参数必须严格使用 schema 字段,禁止自造别名;例如 day_from/day_to 非法,必须改用 day_start/day_end。
12. web_search 仅在"制定学习计划需要查外部资料"时使用如考试日期、课程信息、校历政策等日程排布本身place/move/swap不需要搜索。
13. web_search 拿到 summary 后通常已够用;仅当需要页面详细内容时才调用 web_fetch。
执行规则:
1. 输出格式:先输出一行 <SMARTFLOW_DECISION>{JSON 决策}</SMARTFLOW_DECISION>然后换行输出给用户看的自然语言正文。JSON 中不要包含 speak 字段——用户可见的话放在标签之后。
2. 读操作action=continue + tool_call。
3. 写操作(日程变更,如 place/move/swap/batch_move/unplace/spread_even/min_context_switchaction=confirm + tool_call。
4. 缺关键上下文且无法通过工具补齐action=ask_user。
5. 任务完成action=done并在 goal_check 总结完成证据。
6. 流程应正式终止action=abort。`
// BuildExecuteSystemPrompt 返回执行阶段系统提示词(有 plan 模式)。
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_callaction=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 且未调用任何日程修改工具,禁止承诺“接下来会自动排程”
`)
}

View File

@@ -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=%spacks=[%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, "- 阶段约束:粗排已完成,本轮只微调 suggestedexisting 仅作已安排事实参考,不作为可移动目标。")
}
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 "stringJSON字符串", `{"tool":"query_available_slots","count":12,"strict_count":8,"embedded_count":4,"slots":[{"day":5,"week":12,"day_of_week":3,"slot_start":1,"slot_end":2,"slot_type":"empty"}]}`
case "query_target_tasks":
@@ -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 "stringJSON字符串", `{"tool":"web_search","query":"检索关键词","count":2,"items":[{"title":"搜索结果标题","url":"https://example.com/page","snippet":"摘要片段...","domain":"example.com","published_at":"2025-04-10"}]}`
case "web_fetch":
return "stringJSON字符串", `{"tool":"web_fetch","url":"https://example.com/page","title":"页面标题","content":"正文内容...","truncated":false}`
case "analyze_health":
return "stringJSON字符串", `{"tool":"analyze_health","success":true,"metrics":{"rhythm":{"avg_switches_per_day":1.1,"max_switch_count":4,"heavy_adjacent_days":2,"same_type_transition_ratio":0.58,"block_balance":0,"fragmented_count":0,"compressed_run_count":0},"tightness":{"locally_movable_task_count":3,"avg_local_alternative_slots":1.7,"cross_class_swap_options":1,"forced_heavy_adjacent_days":0,"tightness_level":"tight"},"can_close":false},"decision":{"should_continue_optimize":true,"recommended_operation":"swap","primary_problem":"第4天存在高认知背靠背","candidates":[{"candidate_id":"swap_35_44","tool":"swap","arguments":{"task_a":35,"task_b":44}}]}}`
case "analyze_rhythm":
return "stringJSON字符串", `{"tool":"analyze_rhythm","success":true,"metrics":{"overview":{"avg_switches_per_day":3.4,"max_switch_day":4,"max_switch_count":5,"heavy_adjacent_days":2,"long_high_intensity_days":1,"same_type_transition_ratio":0.42}}}`
case "analyze_tolerance":
return "stringJSON字符串", `{"tool":"analyze_tolerance","success":true,"metrics":{"overall":{"fragmentation_rate":0.52,"days_without_buffer":1}}}`
case "upsert_task_class":
return "stringJSON字符串", `{"tool":"upsert_task_class","success":true,"task_class_id":123,"created":true,"validation":{"ok":true,"issues":[]},"error":"","error_code":""}`
default:
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
}

View File

@@ -0,0 +1,33 @@
package newagentprompt
import (
"fmt"
"strings"
)
func fallbackExecuteText(value string, fallback string) string {
if text := strings.TrimSpace(value); text != "" {
return text
}
return fallback
}
func compactHealthAny(value any) string {
if value == nil {
return ""
}
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
case bool:
if typed {
return "true"
}
return "false"
case int:
return fmt.Sprintf("%d", typed)
case float64:
return fmt.Sprintf("%.0f", typed)
}
return strings.TrimSpace(fmt.Sprintf("%v", value))
}

View File

@@ -0,0 +1,106 @@
package newagentprompt
import (
"fmt"
"strings"
)
// summarizeExecuteAnalyzeHealthObservationV2 把 analyze_health 结果压成更短的单行摘要。
//
// 职责边界:
// 1. 只保留 execute 下一步真正需要消费的裁决字段,不重复展开整份 metrics。
// 2. 若存在候选,会优先展示“候选数量 + 前两个候选工具”,帮助模型迅速进入选择题。
// 3. 这里只做摘要,不负责改变决策含义;真实判定仍以 analyze_health 原始 JSON 为准。
func summarizeExecuteAnalyzeHealthObservationV2(payload map[string]any) string {
decision, _ := payload["decision"].(map[string]any)
metrics, _ := payload["metrics"].(map[string]any)
rhythmMetrics, _ := metrics["rhythm"].(map[string]any)
tightnessMetrics, _ := metrics["tightness"].(map[string]any)
candidates, _ := decision["candidates"].([]any)
parts := make([]string, 0, 7)
if text := compactHealthAny(decision["should_continue_optimize"]); text != "" {
parts = append(parts, "continue="+text)
}
if text := strings.TrimSpace(asExecuteString(decision["recommended_operation"])); text != "" {
parts = append(parts, "recommended="+text)
}
if text := strings.TrimSpace(asExecuteString(tightnessMetrics["tightness_level"])); text != "" {
parts = append(parts, "tightness="+text)
}
if text := buildBlockBalanceSummary(rhythmMetrics); text != "" {
parts = append(parts, text)
}
if text := compactHealthAny(decision["is_forced_imperfection"]); text != "" {
parts = append(parts, "forced="+text)
}
if len(candidates) > 0 {
parts = append(parts, fmt.Sprintf("candidates=%d", len(candidates)))
if preview := compactHealthCandidatePreview(candidates); preview != "" {
parts = append(parts, "options="+preview)
}
}
if text := strings.TrimSpace(asExecuteString(decision["primary_problem"])); text != "" {
parts = append(parts, "problem="+compactExecuteText(text, 36))
}
if len(parts) == 0 {
return "返回了健康裁决结果。"
}
return strings.Join(parts, " | ")
}
// buildBlockBalanceSummary 把 block_balance 连同正负来源一起压成单段摘要。
//
// 职责边界:
// 1. 这里只做 execute 摘要层的可读性补充,避免 LLM 只看到 balance=0 却看不到来源。
// 2. 不改变 analyze_health 原始 JSON 结构;原始结构仍由 metrics.rhythm 提供完整字段。
// 3. 若三个字段都缺失,则直接留空,避免构造误导性的默认值。
func buildBlockBalanceSummary(rhythmMetrics map[string]any) string {
if len(rhythmMetrics) == 0 {
return ""
}
blockBalance := compactHealthAny(rhythmMetrics["block_balance"])
fragmentedCount := compactHealthAny(rhythmMetrics["fragmented_count"])
compressedCount := compactHealthAny(rhythmMetrics["compressed_run_count"])
if blockBalance == "" && fragmentedCount == "" && compressedCount == "" {
return ""
}
return fmt.Sprintf(
"block_balance=%s(fragmented=%s,compressed=%s)",
fallbackExecuteText(blockBalance, "?"),
fallbackExecuteText(fragmentedCount, "?"),
fallbackExecuteText(compressedCount, "?"),
)
}
func compactHealthCandidatePreview(candidates []any) string {
if len(candidates) == 0 {
return ""
}
preview := make([]string, 0, 2)
for _, raw := range candidates {
item, _ := raw.(map[string]any)
if len(item) == 0 {
continue
}
id := strings.TrimSpace(asExecuteString(item["candidate_id"]))
tool := strings.TrimSpace(asExecuteString(item["tool"]))
if id == "" && tool == "" {
continue
}
switch {
case id != "" && tool != "":
preview = append(preview, id+":"+tool)
case id != "":
preview = append(preview, id)
default:
preview = append(preview, tool)
}
if len(preview) >= 2 {
break
}
}
return strings.Join(preview, ",")
}

View File

@@ -0,0 +1,104 @@
package newagentprompt
import (
"fmt"
"strings"
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
)
// renderExecuteNextStepHintV2 生成 execute.msg3 的轻量方向提示。
//
// 设计目标:
// 1. 主动优化模式下,只强调“先 analyze_health再从 candidates 里选”,不再散发额外搜索暗示。
// 2. 普通链路仍保留必要的业务引导,避免误伤用户明确提出的普通调整请求。
// 3. 提示只给方向,不替模型代填最终写参数。
func renderExecuteNextStepHintV2(
state *newagentmodel.CommonState,
latestAnalyze string,
latestMutation string,
roughBuildDone bool,
) string {
if state == nil {
return ""
}
activeDomain := strings.TrimSpace(state.ActiveToolDomain)
activePacks := newagenttools.ResolveEffectiveToolPacks(state.ActiveToolDomain, state.ActiveToolPacks)
if state.ActiveOptimizeOnly {
switch {
case activeDomain == "" && roughBuildDone:
return "当前是粗排后主动优化专用模式;先激活 schedule并只围绕 analyze_health -> move/swap 候选闭环推进。"
case !state.HealthCheckDone:
return "当前是粗排后主动优化专用模式;先调 analyze_health等待后端给出 candidates再做选择。"
case !state.HealthIsFeasible || strings.EqualFold(strings.TrimSpace(state.HealthRecommendedOperation), "ask_user"):
return "analyze_health 已判定当前更像时间窗或信息约束问题;不要继续挪动,先把冲突或缺失点明确告诉用户。"
case !state.HealthShouldContinueOptimize:
return "analyze_health 已判定当前无需继续主动优化;若用户没有新增要求,直接收口。"
default:
return "当前是粗排后主动优化专用模式;直接从 analyze_health 的 decision.candidates 里选一个合法 move/swap 执行,不要再自己搜索读工具。"
}
}
if activeDomain == "schedule" && state.HealthCheckDone {
switch {
case !state.HealthShouldContinueOptimize && state.HealthIsForcedImperfection:
return fmt.Sprintf(
"analyze_health 已判定当前更像约束代价tightness=%s主问题=%s。优先考虑收口。",
fallbackExecuteText(state.HealthTightnessLevel, "unknown"),
fallbackExecuteText(state.HealthPrimaryProblem, "无"),
)
case !state.HealthShouldContinueOptimize:
return fmt.Sprintf(
"analyze_health 已判定当前没有更值得继续处理的局部问题:%s。若用户未追加新要求优先收口。",
fallbackExecuteText(state.HealthPrimaryProblem, "当前可直接收口"),
)
case state.HealthStagnationCount > 0:
return fmt.Sprintf(
"最近诊断已连续 %d 次无明显改善;若本轮仍不能让主问题变轻,优先收口。当前主问题:%s。",
state.HealthStagnationCount,
fallbackExecuteText(state.HealthPrimaryProblem, "无"),
)
case strings.EqualFold(strings.TrimSpace(state.HealthRecommendedOperation), "swap"):
return fmt.Sprintf(
"当前主问题:%s。优先在已有落位之间做局部 swap别把问题扩散到更远的天数。",
fallbackExecuteText(state.HealthPrimaryProblem, "无"),
)
case strings.EqualFold(strings.TrimSpace(state.HealthRecommendedOperation), "move"):
return fmt.Sprintf(
"当前主问题:%s。若要 move只在近范围合法落点里小修不要做全窗口搜索。",
fallbackExecuteText(state.HealthPrimaryProblem, "无"),
)
}
}
if activeDomain == "" {
if roughBuildDone {
return `先激活 schedule 业务域;当前是粗排后的微调场景,通常至少需要 mutation+analyze。若要按统一条件逐个处理一批任务再加 packs=["queue"]。`
}
return `先判断当前任务属于哪个业务域,再用 context_tools_add 激活对应工具。`
}
if activeDomain == "schedule" &&
strings.Contains(latestMutation, "batch_move") &&
(strings.Contains(latestMutation, "缺少") || strings.Contains(latestMutation, "无效")) {
return `当前 batch_move 路径受参数约束;若要处理一批符合同一条件的任务,优先加 packs=["queue"] 逐个处理。`
}
if activeDomain == "schedule" &&
latestAnalyze != "" &&
strings.Contains(latestAnalyze, "metrics") &&
!containsExecutePack(activePacks, newagenttools.ToolPackQueue) {
return `若诊断已经完成,下一步应转入读事实或写操作,不要重复 analyze_health涉及同类批量任务时优先考虑 packs=["queue"]。`
}
if activeDomain == "taskclass" &&
state.TaskClassUpsertLastTried &&
!state.TaskClassUpsertLastSuccess {
return `先根据 validation.issues 补齐缺失字段,再重试 upsert_task_class不要直接收口。`
}
return ""
}

View File

@@ -0,0 +1,318 @@
package newagentprompt
import (
"fmt"
"strings"
"time"
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
)
const (
executeRulePackCoreMin = "core_min"
executeRulePackSafetyHard = "safety_hard"
executeRulePackContextProtocol = "context_protocol"
executeRulePackModePlan = "mode_plan"
executeRulePackModeReAct = "mode_react"
executeRulePackDomainSchedule = "domain_schedule"
executeRulePackDomainTaskClass = "domain_taskclass"
executeRulePackScheduleMutation = "schedule_mutation"
executeRulePackScheduleAnalyze = "schedule_analyze"
executeRulePackScheduleWeb = "schedule_web"
executeRulePackMicroRoughDone = "micro_rough_build_done"
executeRulePackMicroDiagLoop = "micro_diag_tune_loop"
executeRulePackMicroQueue = "micro_queue_chain"
executeRulePackMicroTaskRetry = "micro_taskclass_retry"
)
const executeSystemPromptBaseWithPlan = `
你叫 SmartMate是时伴SmartMate的中文 AI 排程伙伴,面向大学生提供陪伴式日程管理与日常协助。
你擅长课表与任务安排、任务管理、学习规划和随口记,也可以正常回答日常问答、生活建议、信息整理、分析讨论等非排程问题。
你的目标是像一个越用越懂用户的伙伴一样,结合历史对话、长期记忆和当前上下文,给出贴心、清晰、可信的帮助。
你当前处于“计划执行”模式。你必须围绕当前计划步骤推进,并通过 SMARTFLOW_DECISION 输出结构化动作。`
const executeSystemPromptBaseReAct = `
你叫 SmartMate是时伴SmartMate的中文 AI 排程伙伴,面向大学生提供陪伴式日程管理与日常协助。
你擅长课表与任务安排、任务管理、学习规划和随口记,也可以正常回答日常问答、生活建议、信息整理、分析讨论等非排程问题。
你的目标是像一个越用越懂用户的伙伴一样,结合历史对话、长期记忆和当前上下文,给出贴心、清晰、可信的帮助。
你当前处于“自由执行ReAct”模式。你需要根据当前目标自主推进、按需调用工具并通过 SMARTFLOW_DECISION 输出结构化动作。`
type executeRulePack struct {
Name string
Content string
}
// renderExecuteRulePackSection 渲染 execute.msg0 的动态规则包区域。
//
// 1. 这里负责“选哪些包 + 以什么顺序展示”,不负责工具目录本身。
// 2. 固定先放通用硬约束,再放 mode/domain/micro 包,保证模型先读边界后读特例。
// 3. 如果没有任何可展示规则包,则直接返回空串,避免无意义占位。
func renderExecuteRulePackSection(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext) (string, []string) {
packs := selectExecuteRulePacks(state, ctx)
if len(packs) == 0 {
return "", nil
}
lines := []string{"执行规则包msg0 动态注入):"}
names := make([]string, 0, len(packs))
for _, pack := range packs {
content := strings.TrimSpace(pack.Content)
if content == "" {
continue
}
lines = append(lines, fmt.Sprintf("[%s]", pack.Name))
lines = append(lines, content)
names = append(names, pack.Name)
}
if len(names) == 0 {
return "", nil
}
return strings.Join(lines, "\n"), names
}
func selectExecuteRulePacks(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext) []executeRulePack {
selected := make([]executeRulePack, 0, 8)
seen := map[string]bool{}
appendPack := func(pack executeRulePack) {
name := strings.TrimSpace(pack.Name)
if name == "" || seen[name] {
return
}
seen[name] = true
selected = append(selected, pack)
}
appendPack(buildExecuteCoreMinPack())
appendPack(buildExecuteSafetyHardPack())
appendPack(buildExecuteContextProtocolPack())
if state != nil && state.HasPlan() {
appendPack(buildExecuteModePlanPack())
} else {
appendPack(buildExecuteModeReActPack())
}
switch normalizeExecuteToolDomain(readExecuteActiveToolDomain(state)) {
case "schedule":
activePacks := readExecuteActiveToolPacks(state)
appendPack(buildExecuteSchedulePack())
if hasExecutePack(activePacks, newagenttools.ToolPackQueue) {
appendPack(buildExecuteQueueMicroPack())
}
if hasExecutePack(activePacks, newagenttools.ToolPackMutation) {
appendPack(buildExecuteScheduleMutationPack())
}
if hasExecutePack(activePacks, newagenttools.ToolPackAnalyze) {
appendPack(buildExecuteScheduleAnalyzePackV2())
}
if hasExecutePack(activePacks, newagenttools.ToolPackWeb) {
appendPack(buildExecuteScheduleWebPack())
}
case "taskclass":
appendPack(buildExecuteTaskClassPack())
}
if hasExecuteRoughBuildDone(ctx) {
appendPack(buildExecuteRoughDoneMicroPack())
}
if shouldInjectExecuteDiagLoopPack(state, ctx) {
appendPack(buildExecuteDiagLoopMicroPackV2())
}
if state != nil && state.TaskClassUpsertLastTried && !state.TaskClassUpsertLastSuccess {
appendPack(buildExecuteTaskClassRetryMicroPack())
}
return selected
}
func readExecuteActiveToolDomain(state *newagentmodel.CommonState) string {
if state == nil {
return ""
}
return strings.TrimSpace(state.ActiveToolDomain)
}
func readExecuteActiveToolPacks(state *newagentmodel.CommonState) []string {
if state == nil {
return nil
}
return newagenttools.ResolveEffectiveToolPacks(state.ActiveToolDomain, state.ActiveToolPacks)
}
func hasExecutePack(packs []string, target string) bool {
target = strings.ToLower(strings.TrimSpace(target))
if target == "" {
return false
}
for _, pack := range packs {
if strings.ToLower(strings.TrimSpace(pack)) == target {
return true
}
}
return false
}
// containsExecutePack 兼容旧调用点。
//
// 1. 这里只做别名转发,不引入第二套判断口径。
// 2. 保留它是为了避免下一轮再因为历史调用点而误删。
func containsExecutePack(packs []string, target string) bool {
return hasExecutePack(packs, target)
}
func normalizeExecuteToolDomain(domain string) string {
switch strings.ToLower(strings.TrimSpace(domain)) {
case "schedule":
return "schedule"
case "taskclass":
return "taskclass"
default:
return ""
}
}
func buildExecuteCoreMinPack() executeRulePack {
return executeRulePack{
Name: executeRulePackCoreMin,
Content: strings.TrimSpace(fmt.Sprintf(`
- 当前时间锚点:%s。涉及“今天/明天/本周”等相对时间时,先按该锚点换算。
- 用户意图优先:只推进用户当前明确要求;未明确部分优先 ask_user。
- 先事实后动作:优先读工具补齐事实,再决定下一步。
- 只要决定调用 place/move/swap/batch_move/unplace 这类写工具,就必须输出 action=confirmcontinue + 写工具无效。
- 输出格式固定:先 <SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>,再输出用户可见正文。`,
buildExecuteNowAnchorLine())),
}
}
func buildExecuteNowAnchorLine() string {
now := time.Now()
weekdays := []string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"}
return fmt.Sprintf("%s%s%s", now.Format("2006-01-02 15:04:05 -07:00"), weekdays[int(now.Weekday())], now.Format("MST"))
}
func buildExecuteSafetyHardPack() executeRulePack {
return executeRulePack{
Name: executeRulePackSafetyHard,
Content: strings.TrimSpace(`
- 严禁伪造工具结果;若新结果与既有事实冲突,先重查一次再决定。
- 工具参数必须严格使用 schema 字段名,禁止自造别名。
- JSON 只保留当前 action 必需字段;不要输出空字符串、空对象、空数组或 null 占位。
- P1 阶段禁止调用 min_context_switch。
- 连续两轮同类读查询后,必须转执行 / ask_user / 明确说明阻塞,不能无限空转。`),
}
}
func buildExecuteContextProtocolPack() executeRulePack {
return executeRulePack{
Name: executeRulePackContextProtocol,
Content: strings.TrimSpace(`
- msg0 动态区初始仅保留 context_tools_add / context_tools_remove。
- 需要业务工具前先 context_tools_add排程用 domain="schedule",任务类写入用 domain="taskclass"。
- schedule 可选 packs=["mutation","analyze","detail_read","deep_analyze","queue","web"]core 固定注入,不要显式传 core。
- 只在业务方向切换时再 removedone 后的动态区清理由系统自动完成,不必手动 remove。
- 如果目标工具当前不在可用列表,先 add 对应 domain / packs再继续执行。`),
}
}
func buildExecuteModePlanPack() executeRulePack {
return executeRulePack{
Name: executeRulePackModePlan,
Content: strings.TrimSpace(`
- 当前为计划执行模式:必须围绕当前计划步骤推进。
- 未满足 done_when 时,只能 continue / confirm / ask_user禁止 next_plan。
- next_plan / done 时goal_check 必须是字符串,并对照 done_when 给出完成证据。
- 禁止跳步执行后续计划。`),
}
}
func buildExecuteModeReActPack() executeRulePack {
return executeRulePack{
Name: executeRulePackModeReAct,
Content: strings.TrimSpace(`
- 当前为自由执行ReAct模式可自主决定 continue / confirm / ask_user / done / abort。
- 如果关键事实无法通过工具补齐,优先 ask_user不做猜测落库。
- 自主推进时要小步快跑,优先闭合当前局部问题,不要发散成大范围开放搜索。`),
}
}
func buildExecuteSchedulePack() executeRulePack {
return executeRulePack{
Name: executeRulePackDomainSchedule,
Content: strings.TrimSpace(`
- 当前业务域为 schedule只处理当前目标任务类不重排无关内容。
- existing 只作事实参考;真正可调对象优先看 suggested。
- 同任务类内部顺序必须保持,任何越过前驱/后继边界的移动都会被写工具拒绝。`),
}
}
func buildExecuteScheduleMutationPack() executeRulePack {
return executeRulePack{
Name: executeRulePackScheduleMutation,
Content: strings.TrimSpace(`
- mutation 包负责真正落日程写操作place / move / swap / batch_move / unplace。
- 写操作必须走 action=confirm不要在 continue 里偷跑写工具。
- 若是主动优化链路,优先在后端给出的合法候选中选择,不要自己再全窗搜索新坑位。`),
}
}
func buildExecuteQueueMicroPack() executeRulePack {
return executeRulePack{
Name: executeRulePackMicroQueue,
Content: strings.TrimSpace(`
- queue 包适合“按同一条件逐个处理一批任务”的场景,例如把所有早八任务依次挪走。
- query_target_tasks 可结合 enqueue=true 先把候选任务入队,再用 queue_pop_head / queue_apply_head_move / queue_skip_head 顺序处理。
- 当你需要连续处理多条相似任务时,优先走 queue避免把整批任务细节长期堆在上下文里。`),
}
}
func buildExecuteScheduleWebPack() executeRulePack {
return executeRulePack{
Name: executeRulePackScheduleWeb,
Content: strings.TrimSpace(`
- web 包只用于补充通用学习资料或通识信息不用于捏造个人时间、考试时间、DDL 或排程事实。
- web_search 先粗搜web_fetch 再抓正文;不确定时宁可不用,也不要把网页结果当成排程事实直接写入。`),
}
}
func buildExecuteTaskClassPack() executeRulePack {
return executeRulePack{
Name: executeRulePackDomainTaskClass,
Content: strings.TrimSpace(`
- taskclass 域只负责生成或修正任务类,不代表已经开始排程。
- upsert_task_class 若返回 validation.ok=false必须先处理 validation.issues再考虑重试或 ask_user。
- subject_type / difficulty_level / cognitive_intensity 是任务类语义画像必填项;优先静默推断,只有确实无法判断时再 ask_user。
- excluded_slots 取值应与系统节次定义一致excluded_days_of_week 使用 1~7 表示周一到周日。`),
}
}
func buildExecuteRoughDoneMicroPack() executeRulePack {
return executeRulePack{
Name: executeRulePackMicroRoughDone,
Content: strings.TrimSpace(`
- 已有 rough_build_done本轮以微调为主不要把任务重新当成“未排入”再全量 place。
- 若当前问题已经可接受,应优先收口,不要为了追求完美继续反复局部打磨。`),
}
}
func buildExecuteTaskClassRetryMicroPack() executeRulePack {
return executeRulePack{
Name: executeRulePackMicroTaskRetry,
Content: strings.TrimSpace(`
- 最近一次 upsert_task_class 失败时,优先围绕 validation.issues 修补。
- 问题未解决前,不要用 done 假装收口;要么重试,要么 ask_user 补关键信息。`),
}
}
func shouldInjectExecuteDiagLoopPack(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext) bool {
if state == nil || !hasExecuteRoughBuildDone(ctx) {
return false
}
if normalizeExecuteToolDomain(readExecuteActiveToolDomain(state)) != "schedule" {
return false
}
activePacks := readExecuteActiveToolPacks(state)
return hasExecutePack(activePacks, newagenttools.ToolPackAnalyze) &&
hasExecutePack(activePacks, newagenttools.ToolPackMutation)
}

View File

@@ -0,0 +1,27 @@
package newagentprompt
import "strings"
func buildExecuteScheduleAnalyzePackV2() executeRulePack {
return executeRulePack{
Name: executeRulePackScheduleAnalyze,
Content: strings.TrimSpace(`
- analyze 包已激活:优先使用 analyze_health 判断“现在还值不值得继续主动优化”,不要把它当成全能体检表。
- 若需要维度级细诊断(如 rhythm再 add packs=["deep_analyze"],不要默认把所有分析都铺开。
- 在主动优化专用模式里analyze_health 会直接返回 decision.candidates这些就是后端已经验证合法、并且复诊后确实变好的 move/swap 候选。
- 一旦 decision.candidates 已经给出,下一步应直接从候选里选一个去执行;不要再自己搜索 query_target_tasks / query_available_slots。
- 若 analyze_health 显示 should_continue_optimize=false优先收口不要因为“理论上还还能动”就继续局部修补。`),
}
}
func buildExecuteDiagLoopMicroPackV2() executeRulePack {
return executeRulePack{
Name: executeRulePackMicroDiagLoop,
Content: strings.TrimSpace(`
- 粗排后的主动优化允许多轮 execute但每一轮都必须围绕“当前主问题”做局部、小范围、可解释的调整。
- 在主动优化专用模式里analyze_health 负责“出候选题”,你只负责在 decision.candidates 里做选择,不负责重新全窗搜点。
- 若当前问题主要来自时间窗过紧,或所有合法候选都只是平移没有变轻,应接受局部不完美并收口。
- 若连续两轮诊断没有明显改善,或当前 recommended_operation 已经是 close应优先收口。
- 主动优化优先在已有落位之间做选择swap 优先move 次之;不要做全窗口搜索。`),
}
}

View File

@@ -8,58 +8,46 @@ import (
"github.com/cloudwego/eino/schema"
)
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_idsneeds_rough_build=true 时必填,从上下文"任务类 ID"字段读取
- plan_steps[].done_when可选建议写完成判定
- needs_rough_build仅满足粗排识别条件时为 true否则省略
- task_class_idsneeds_rough_build=true 时必填,从上下文读取
- context_hook可选仅用于给 execute 阶段提供注入建议
- context_hook.domainschedule / taskclass
- context_hook.packsstring 数组可选core 固定注入,不要填写 core
- context_hook.reason可选说明为何建议该注入
注意:JSON 中不要包含 speak 字段。给用户看的话放在 </SMARTFLOW_DECISION> 标签之后。
合法示例:
<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,
))
}

View File

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

View File

@@ -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 返回节点自行提供的历史视图。
//
// 说明:

View File

@@ -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 == "" {

View File

@@ -0,0 +1,37 @@
package newagenttools
import "strings"
var activeOptimizeAllowedTools = map[string]struct{}{
ToolNameContextToolsAdd: {},
ToolNameContextToolsRemove: {},
"analyze_health": {},
"move": {},
"swap": {},
}
// IsToolAllowedInActiveOptimize 判定工具是否允许出现在“粗排后主动优化专用模式”里。
//
// 职责边界:
// 1. 这里只做场景级白名单裁剪,不参与工具是否已注册、是否被临时禁用、是否需要 confirm 的判断;
// 2. 该白名单只服务于“首次粗排后自动微调”链路,避免 LLM 在主动优化时重新暴露大量读工具;
// 3. context_tools_add/remove 仍保留,是为了兼容系统级动态区协议,但不代表会重新放开其它业务工具。
func IsToolAllowedInActiveOptimize(name string) bool {
_, ok := activeOptimizeAllowedTools[strings.TrimSpace(name)]
return ok
}
// FilterSchemasForActiveOptimize 过滤出主动优化专用模式允许暴露给 LLM 的工具 schema。
func FilterSchemasForActiveOptimize(schemas []ToolSchemaEntry) []ToolSchemaEntry {
if len(schemas) == 0 {
return nil
}
filtered := make([]ToolSchemaEntry, 0, len(schemas))
for _, item := range schemas {
if !IsToolAllowedInActiveOptimize(item.Name) {
continue
}
filtered = append(filtered, item)
}
return filtered
}

View File

@@ -0,0 +1,305 @@
package newagenttools
import (
"encoding/json"
"strings"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
)
type contextToolsAddResult struct {
Tool string `json:"tool"`
Success bool `json:"success"`
Action string `json:"action"`
Domain string `json:"domain,omitempty"`
Packs []string `json:"packs,omitempty"`
Mode string `json:"mode,omitempty"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
}
type contextToolsRemoveResult struct {
Tool string `json:"tool"`
Success bool `json:"success"`
Action string `json:"action"`
Domain string `json:"domain,omitempty"`
Packs []string `json:"packs,omitempty"`
All bool `json:"all,omitempty"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
}
// NewContextToolsAddHandler 创建 context_tools_add 工具。
//
// 职责边界:
// 1. 仅负责校验 domain/mode/packs 并返回结构化结果,不直接修改流程状态;
// 2. 真正的“激活态写回”由 execute 节点根据工具结果回写 CommonState
// 3. schedule 支持可选 packstaskclass 目前不支持可选 packs。
func NewContextToolsAddHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) string {
_ = state
domain := NormalizeToolDomain(readContextToolString(args["domain"]))
if domain == "" {
return marshalContextToolsAddResult(contextToolsAddResult{
Tool: ToolNameContextToolsAdd,
Success: false,
Action: "reject",
Error: "参数非法domain 仅支持 schedule/taskclass",
ErrorCode: "invalid_domain",
})
}
mode := strings.ToLower(strings.TrimSpace(readContextToolString(args["mode"])))
if mode == "" {
mode = "replace"
}
if mode != "replace" && mode != "merge" {
return marshalContextToolsAddResult(contextToolsAddResult{
Tool: ToolNameContextToolsAdd,
Success: false,
Action: "reject",
Domain: domain,
Error: "参数非法mode 仅支持 replace/merge",
ErrorCode: "invalid_mode",
})
}
packsRaw := readContextToolStringSlice(args["packs"])
packs, errCode, errText := validateContextPacks(domain, packsRaw, false)
if errCode != "" {
return marshalContextToolsAddResult(contextToolsAddResult{
Tool: ToolNameContextToolsAdd,
Success: false,
Action: "reject",
Domain: domain,
Error: errText,
ErrorCode: errCode,
})
}
// schedule 未显式传 packs 时默认启用最小可用包mutation + analyze
if domain == ToolDomainSchedule && len(packsRaw) == 0 {
packs = ResolveEffectiveToolPacks(domain, nil)
}
return marshalContextToolsAddResult(contextToolsAddResult{
Tool: ToolNameContextToolsAdd,
Success: true,
Action: "activate",
Domain: domain,
Packs: packs,
Mode: mode,
Message: "已激活工具域,可继续调用对应业务工具。",
})
}
}
// NewContextToolsRemoveHandler 创建 context_tools_remove 工具。
//
// 职责边界:
// 1. 仅解析 domain/all/packs 语义并返回结构化结果,不直接触碰上下文存储;
// 2. all=true 表示清空动态区业务工具domain+packs 表示移除该域下指定二级包;
// 3. 仅 schedule 支持按 packs 移除,且 core 不允许显式移除。
func NewContextToolsRemoveHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) string {
_ = state
all := readContextToolBool(args["all"])
domainRaw := strings.ToLower(strings.TrimSpace(readContextToolString(args["domain"])))
packsRaw := readContextToolStringSlice(args["packs"])
// 兼容写法domain=all 视为清空全部。
if domainRaw == "all" {
all = true
}
if all {
return marshalContextToolsRemoveResult(contextToolsRemoveResult{
Tool: ToolNameContextToolsRemove,
Success: true,
Action: "clear_all",
All: true,
Message: "已移除全部业务工具域,仅保留上下文管理工具。",
})
}
domain := NormalizeToolDomain(domainRaw)
if domain == "" {
return marshalContextToolsRemoveResult(contextToolsRemoveResult{
Tool: ToolNameContextToolsRemove,
Success: false,
Action: "reject",
Error: "参数非法:需提供 domain=schedule/taskclass 或 all=true",
ErrorCode: "invalid_domain",
})
}
packs, errCode, errText := validateContextPacks(domain, packsRaw, true)
if errCode != "" {
return marshalContextToolsRemoveResult(contextToolsRemoveResult{
Tool: ToolNameContextToolsRemove,
Success: false,
Action: "reject",
Domain: domain,
Error: errText,
ErrorCode: errCode,
})
}
if len(packs) > 0 {
return marshalContextToolsRemoveResult(contextToolsRemoveResult{
Tool: ToolNameContextToolsRemove,
Success: true,
Action: "deactivate_packs",
Domain: domain,
Packs: packs,
Message: "已移除指定工具包。",
})
}
return marshalContextToolsRemoveResult(contextToolsRemoveResult{
Tool: ToolNameContextToolsRemove,
Success: true,
Action: "deactivate",
Domain: domain,
Message: "已移除指定工具域。",
})
}
}
func validateContextPacks(domain string, packs []string, forRemove bool) ([]string, string, string) {
normalizedDomain := NormalizeToolDomain(domain)
if normalizedDomain == "" {
return nil, "invalid_domain", "参数非法domain 非法"
}
if len(packs) == 0 {
return nil, "", ""
}
if normalizedDomain == ToolDomainTaskClass {
return nil, "unsupported_packs_for_domain", "参数非法taskclass 暂不支持 packs"
}
normalized := make([]string, 0, len(packs))
seen := make(map[string]struct{}, len(packs))
for _, raw := range packs {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
continue
}
pack := NormalizeToolPack(normalizedDomain, trimmed)
if pack == "" {
return nil, "invalid_pack", "参数非法:存在不支持的 pack"
}
if IsFixedToolPack(normalizedDomain, pack) {
if forRemove {
return nil, "fixed_pack_forbidden", "参数非法core 为固定包,不允许 remove"
}
return nil, "fixed_pack_forbidden", "参数非法core 为固定包,不允许 add"
}
if _, exists := seen[pack]; exists {
continue
}
seen[pack] = struct{}{}
normalized = append(normalized, pack)
}
if len(normalized) == 0 {
return nil, "invalid_pack", "参数非法packs 为空或无效"
}
return normalized, "", ""
}
func readContextToolString(raw any) string {
text, _ := raw.(string)
return strings.TrimSpace(text)
}
func readContextToolStringSlice(raw any) []string {
switch typed := raw.(type) {
case []string:
out := make([]string, 0, len(typed))
for _, item := range typed {
text := strings.TrimSpace(item)
if text == "" {
continue
}
out = append(out, text)
}
return out
case []any:
out := make([]string, 0, len(typed))
for _, item := range typed {
text, ok := item.(string)
if !ok {
continue
}
text = strings.TrimSpace(text)
if text == "" {
continue
}
out = append(out, text)
}
return out
case string:
text := strings.TrimSpace(typed)
if text == "" {
return nil
}
parts := strings.Split(text, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
out = append(out, part)
}
return out
default:
return nil
}
}
func readContextToolBool(raw any) bool {
switch v := raw.(type) {
case bool:
return v
case string:
value := strings.ToLower(strings.TrimSpace(v))
return value == "1" || value == "true" || value == "yes"
case float64:
return v != 0
case float32:
return v != 0
case int:
return v != 0
case int8:
return v != 0
case int16:
return v != 0
case int32:
return v != 0
case int64:
return v != 0
default:
return false
}
}
func marshalContextToolsAddResult(result contextToolsAddResult) string {
raw, err := json.Marshal(result)
if err != nil {
return `{"tool":"context_tools_add","success":false,"action":"reject","error":"result encode failed","error_code":"encode_failed"}`
}
return string(raw)
}
func marshalContextToolsRemoveResult(result contextToolsRemoveResult) string {
raw, err := json.Marshal(result)
if err != nil {
return `{"tool":"context_tools_remove","success":false,"action":"reject","error":"result encode failed","error_code":"encode_failed"}`
}
return string(raw)
}

View File

@@ -10,36 +10,62 @@ import (
"github.com/LoveLosita/smartflow/backend/newAgent/tools/web"
)
// 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
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,124 @@
package schedule
import "strings"
// buildAnalyzeHealthDecisionV2 生成 analyze_health 在主动优化场景下的最终裁决。
//
// 职责边界:
// 1. 先尊重 base 层的判断:只有 base 明确允许继续优化时,才进入候选枚举。
// 2. 候选只来自后端已经验证合法、并且复诊后确实变好的 move/swap 方案。
// 3. 若没有真正改善的候选,则明确返回 close避免把 LLM 推回开放式全窗搜索。
func buildAnalyzeHealthDecisionV2(
state *ScheduleState,
snapshot analyzeHealthSnapshot,
) analyzeHealthDecision {
base := buildAnalyzeHealthDecisionBase(state, snapshot)
decision := analyzeHealthDecision{
ShouldContinueOptimize: base.ShouldContinueOptimize,
PrimaryProblem: base.PrimaryProblem,
ProblemScope: base.ProblemScope,
IsForcedImperfection: base.IsForcedImperfection,
RecommendedOperation: base.RecommendedOperation,
ImprovementSignal: buildHealthImprovementSignal(
snapshot.Rhythm,
snapshot.Tightness,
base.ProblemScope,
base.RecommendedOperation,
snapshot.Profile,
snapshot.Feasibility,
),
}
if !shouldEnterHealthCandidateLoop(base) {
decision.Candidates = []analyzeHealthCandidate{
buildHealthCloseCandidate("保持当前安排并收口:当前不需要再进入主动优化候选。", snapshot, base),
}
decision.ShouldContinueOptimize = false
return decision
}
bestScan, ok := findBestHealthProblemScanResult(state, snapshot)
if !ok || bestScan.Problem.Kind != healthProblemHeavyAdjacent || bestScan.Problem.Pair == nil {
decision.Candidates = []analyzeHealthCandidate{
buildHealthCloseCandidate("保持当前安排并收口:当前没有值得继续处理的局部认知问题。", snapshot, base),
}
decision.ShouldContinueOptimize = false
decision.PrimaryProblem = "当前没有发现值得继续处理的局部认知问题"
decision.ProblemScope = nil
decision.RecommendedOperation = "close"
if snapshot.Tightness.TightnessLevel == "locked" || snapshot.Tightness.TightnessLevel == "tight" {
decision.IsForcedImperfection = true
}
decision.ImprovementSignal = buildHealthImprovementSignal(
snapshot.Rhythm,
snapshot.Tightness,
decision.ProblemScope,
decision.RecommendedOperation,
snapshot.Profile,
snapshot.Feasibility,
)
return decision
}
decision.PrimaryProblem = bestScan.Problem.Summary
decision.ProblemScope = bestScan.Problem.Scope
decision.Candidates = append(decision.Candidates, bestScan.Candidates...)
decision.Candidates = append(decision.Candidates,
buildHealthCloseCandidate("如果不想继续挪动,也可以保持当前安排并直接收口。", snapshot, base),
)
decision.ShouldContinueOptimize = true
decision.RecommendedOperation = strings.TrimSpace(bestScan.Candidates[0].Tool)
decision.ImprovementSignal = buildHealthImprovementSignal(
snapshot.Rhythm,
snapshot.Tightness,
decision.ProblemScope,
decision.RecommendedOperation,
snapshot.Profile,
snapshot.Feasibility,
)
return decision
}
// findBestHealthProblemScanResult 每轮重扫所有 heavy_adjacent 天,并选出当前收益最高的一天。
//
// 步骤化说明:
// 1. 先收集所有仍需关注的 heavy_adjacent 天;这里只扫描问题天,不改候选类型。
// 2. 再对每一天复用现有单天候选试算逻辑,保持“合法且复诊后确实变好”这一过滤语义不变。
// 3. 最后只返回收益最高且达到最小阈值的一天;最终 decision.candidates 仍只来自这一天天然候选集。
func findBestHealthProblemScanResult(
state *ScheduleState,
snapshot analyzeHealthSnapshot,
) (analyzeHealthProblemScanResult, bool) {
problems := collectRepairableHeavyAdjacentProblems(state, snapshot)
if len(problems) == 0 {
return analyzeHealthProblemScanResult{}, false
}
results := make([]analyzeHealthProblemScanResult, 0, len(problems))
for _, problem := range problems {
scan, ok := buildHealthProblemScanResult(state, snapshot, problem)
if !ok {
continue
}
results = append(results, scan)
}
return selectBestHealthProblemScanResult(results)
}
// shouldEnterHealthCandidateLoop 判断本轮是否应进入“候选式主动优化”。
//
// 说明:
// 1. 只有 base 已判定“值得继续优化”时才放行。
// 2. 当前主动优化闭环只接受 move / swap 两类操作,其它动作不进入候选生成。
// 3. 这样可以挡住 “ask_user / close / forced imperfection” 被后续枚举误覆盖的问题。
func shouldEnterHealthCandidateLoop(base analyzeHealthDecisionBase) bool {
if !base.ShouldContinueOptimize {
return false
}
switch strings.TrimSpace(base.RecommendedOperation) {
case "move", "swap":
return true
default:
return false
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -134,6 +134,13 @@ func MinContextSwitch(state *ScheduleState, taskIDs []int) string {
)
}
}
minContextProposals := make(map[int][]TaskSlot, len(afterByID))
for taskID, after := range afterByID {
minContextProposals[taskID] = []TaskSlot{after.Slot}
}
if err := validateLocalOrderBatchPlacement(state, minContextProposals); err != nil {
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
}
// 4. 全量通过后再原子提交,避免半成品状态。
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 {

View File

@@ -0,0 +1,184 @@
package schedule
import "fmt"
// validateLocalOrderForSinglePlacement 校验单个任务落到目标时段后,是否仍满足同任务类内部顺序约束。
//
// 职责边界:
// 1. 只负责“同任务类内部顺序”这一条规则,不负责冲突、锁定、范围合法性;
// 2. 采用“克隆态 + 假设落位”方式校验,避免直接污染真实 state
// 3. 若任务不属于 task_item / 缺少 task_order / 当前无边界约束,直接放行。
func validateLocalOrderForSinglePlacement(state *ScheduleState, taskID int, targetSlots []TaskSlot) error {
if len(targetSlots) == 0 {
return nil
}
return validateLocalOrderBatchPlacement(state, map[int][]TaskSlot{
taskID: cloneScheduleTaskSlots(targetSlots),
})
}
// validateLocalOrderBatchPlacement 在“多任务同时变更”的假设下做顺序约束校验。
//
// 职责边界:
// 1. 先把所有候选落位一次性写入克隆态,再统一校验,避免 swap/batch/spread_even 出现伪冲突;
// 2. 只校验 proposals 中涉及的任务,因为只要这些任务仍处于各自前驱/后继之间,就不会破坏同类整体顺序;
// 3. 返回首个命中的中文错误,供写工具直接透传给 LLM。
func validateLocalOrderBatchPlacement(state *ScheduleState, proposals map[int][]TaskSlot) error {
if state == nil || len(proposals) == 0 {
return nil
}
clone := state.Clone()
for taskID, slots := range proposals {
task := clone.TaskByStateID(taskID)
if task == nil {
return fmt.Errorf("顺序约束校验失败任务ID %d 不存在", taskID)
}
task.Slots = cloneScheduleTaskSlots(slots)
}
for taskID := range proposals {
if err := validateTaskLocalOrderOnState(clone, taskID); err != nil {
return err
}
}
return nil
}
// validateTaskLocalOrderOnState 判断某个任务在当前假设态下,是否仍处于同任务类前驱/后继之间。
func validateTaskLocalOrderOnState(state *ScheduleState, taskID int) error {
task := state.TaskByStateID(taskID)
if task == nil {
return fmt.Errorf("顺序约束校验失败任务ID %d 不存在", taskID)
}
if !shouldEnforceTaskLocalOrder(*task) || len(task.Slots) == 0 {
return nil
}
prevTask, nextTask := findTaskClassNeighbors(state, *task)
targetStartDay, targetStartSlot, _ := earliestScheduleTaskSlot(task.Slots)
targetEndDay, _, targetEndSlot := latestScheduleTaskSlot(task.Slots)
if prevTask != nil && len(prevTask.Slots) > 0 {
prevEndDay, _, prevEndSlot := latestScheduleTaskSlot(prevTask.Slots)
if !isStrictlyAfter(targetStartDay, targetStartSlot, prevEndDay, prevEndSlot) {
return fmt.Errorf(
"顺序约束不满足:[%d]%s 不能放到%s。它必须晚于同任务类前一个任务 %s 的结束位置(%s。",
task.StateID,
task.Name,
formatTaskSlotsBriefWithState(state, task.Slots),
formatTaskLabel(*prevTask),
formatTaskSlotsBriefWithState(state, prevTask.Slots),
)
}
}
if nextTask != nil && len(nextTask.Slots) > 0 {
nextStartDay, nextStartSlot, _ := earliestScheduleTaskSlot(nextTask.Slots)
if !isStrictlyBefore(targetEndDay, targetEndSlot, nextStartDay, nextStartSlot) {
return fmt.Errorf(
"顺序约束不满足:[%d]%s 不能放到%s。它必须早于同任务类后一个任务 %s 的开始位置(%s。",
task.StateID,
task.Name,
formatTaskSlotsBriefWithState(state, task.Slots),
formatTaskLabel(*nextTask),
formatTaskSlotsBriefWithState(state, nextTask.Slots),
)
}
}
return nil
}
// shouldEnforceTaskLocalOrder 判断任务是否需要参与“同任务类内部顺序”约束。
func shouldEnforceTaskLocalOrder(task ScheduleTask) bool {
return task.Source == "task_item" && task.TaskClassID > 0 && task.TaskOrder > 0
}
// findTaskClassNeighbors 查找同任务类中 order 紧邻当前任务的前驱与后继。
func findTaskClassNeighbors(state *ScheduleState, task ScheduleTask) (prevTask *ScheduleTask, nextTask *ScheduleTask) {
if state == nil || !shouldEnforceTaskLocalOrder(task) {
return nil, nil
}
for i := range state.Tasks {
candidate := &state.Tasks[i]
if candidate.StateID == task.StateID {
continue
}
if !shouldEnforceTaskLocalOrder(*candidate) {
continue
}
if candidate.TaskClassID != task.TaskClassID {
continue
}
if candidate.TaskOrder < task.TaskOrder {
if prevTask == nil || candidate.TaskOrder > prevTask.TaskOrder {
prevTask = candidate
}
continue
}
if candidate.TaskOrder > task.TaskOrder {
if nextTask == nil || candidate.TaskOrder < nextTask.TaskOrder {
nextTask = candidate
}
}
}
return prevTask, nextTask
}
func earliestScheduleTaskSlot(slots []TaskSlot) (day int, slotStart int, slotEnd int) {
if len(slots) == 0 {
return 0, 0, 0
}
best := slots[0]
for i := 1; i < len(slots); i++ {
current := slots[i]
if current.Day < best.Day ||
(current.Day == best.Day && current.SlotStart < best.SlotStart) ||
(current.Day == best.Day && current.SlotStart == best.SlotStart && current.SlotEnd < best.SlotEnd) {
best = current
}
}
return best.Day, best.SlotStart, best.SlotEnd
}
func latestScheduleTaskSlot(slots []TaskSlot) (day int, slotStart int, slotEnd int) {
if len(slots) == 0 {
return 0, 0, 0
}
best := slots[0]
for i := 1; i < len(slots); i++ {
current := slots[i]
if current.Day > best.Day ||
(current.Day == best.Day && current.SlotEnd > best.SlotEnd) ||
(current.Day == best.Day && current.SlotEnd == best.SlotEnd && current.SlotStart > best.SlotStart) {
best = current
}
}
return best.Day, best.SlotStart, best.SlotEnd
}
func isStrictlyAfter(dayA, slotA, dayB, slotB int) bool {
if dayA != dayB {
return dayA > dayB
}
return slotA > slotB
}
func isStrictlyBefore(dayA, slotA, dayB, slotB int) bool {
if dayA != dayB {
return dayA < dayB
}
return slotA < slotB
}
func cloneScheduleTaskSlots(src []TaskSlot) []TaskSlot {
if len(src) == 0 {
return nil
}
dst := make([]TaskSlot, len(src))
copy(dst, src)
return dst
}

View File

@@ -380,7 +380,7 @@ func QueryTargetTasks(state *ScheduleState, args map[string]any) string {
// 5. 队列化(可选):将筛选结果自动纳入“待处理队列”。
//
// 步骤化说明:
// 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
}

View File

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

View File

@@ -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 微调”的临时待处理队列。
//
// 职责边界:

View File

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

View File

@@ -0,0 +1,494 @@
package newagenttools
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
)
// TaskClassUpsertInput 描述任务类写库工具的标准化入参。
//
// 职责边界:
// 1. ID=0 表示创建ID>0 表示更新;
// 2. Request 直接复用现有 UserAddTaskClassRequest 语义,避免多套字段定义漂移;
// 3. Source 用于记录字段来源chat/memory/web不参与业务校验。
type TaskClassUpsertInput struct {
ID int
Request model.UserAddTaskClassRequest
Source string
}
// TaskClassUpsertPersistResult 描述任务类写入持久层后的结果。
type TaskClassUpsertPersistResult struct {
TaskClassID int
Created bool
}
// TaskClassWriteDeps 描述任务类写库工具依赖。
//
// 职责边界:
// 1. 工具层只负责参数标准化与结果包装,不直接依赖 DAO
// 2. UpsertTaskClass 由启动层注入,便于后续替换为 service/DAO 统一实现。
type TaskClassWriteDeps struct {
UpsertTaskClass func(userID int, input TaskClassUpsertInput) (TaskClassUpsertPersistResult, error)
}
type taskClassValidationResult struct {
OK bool `json:"ok"`
Issues []string `json:"issues"`
}
type taskClassUpsertToolResult struct {
Tool string `json:"tool"`
Success bool `json:"success"`
TaskClassID int `json:"task_class_id,omitempty"`
Created bool `json:"created,omitempty"`
Validation taskClassValidationResult `json:"validation"`
Error string `json:"error"`
ErrorCode string `json:"error_code"`
}
// NewTaskClassUpsertToolHandler 创建 upsert_task_class 工具 handler。
//
// 职责边界:
// 1. 只做参数解析、合法性校验、调用依赖、返回统一 JSON
// 2. 不负责草案生成,草案由 prompt+LLM 完成;
// 3. 不依赖 ScheduleState可在纯聊天场景调用execute 会注入 _user_id
func NewTaskClassUpsertToolHandler(deps TaskClassWriteDeps) ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) string {
_ = state
if deps.UpsertTaskClass == nil {
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{"任务类写库依赖未注入"}},
Error: "任务类写库依赖未注入",
ErrorCode: "dependency_missing",
})
}
userID, ok := readUpsertUserID(args["_user_id"])
if !ok || userID <= 0 {
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{"无法识别用户身份"}},
Error: "工具调用失败:无法识别用户身份",
ErrorCode: "missing_user_id",
})
}
input, parseErr := parseTaskClassUpsertInput(args)
if parseErr != nil {
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{parseErr.Error()}},
Error: parseErr.Error(),
ErrorCode: "invalid_args",
})
}
issues := validateTaskClassUpsertRequest(input.Request, input.ID)
if len(issues) > 0 {
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: issues},
Error: strings.Join(issues, ""),
ErrorCode: "validation_failed",
})
}
result, err := deps.UpsertTaskClass(userID, input)
if err != nil {
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{"持久化写入失败"}},
Error: err.Error(),
ErrorCode: "persist_failed",
})
}
if result.TaskClassID <= 0 {
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{"未返回有效 task_class_id"}},
Error: "写入后未返回有效 task_class_id",
ErrorCode: "invalid_persist_result",
})
}
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: true,
TaskClassID: result.TaskClassID,
Created: result.Created,
Validation: taskClassValidationResult{OK: true, Issues: []string{}},
Error: "",
ErrorCode: "",
})
}
}
func parseTaskClassUpsertInput(args map[string]any) (TaskClassUpsertInput, error) {
id := 0
if rawID, exists := args["id"]; exists {
parsedID, ok := readUpsertInt(rawID)
if !ok {
return TaskClassUpsertInput{}, fmt.Errorf("id 参数类型非法,必须为整数")
}
id = parsedID
}
taskClassRaw, ok := args["task_class"]
if !ok {
return TaskClassUpsertInput{}, fmt.Errorf("缺少必填参数 task_class")
}
taskClassMap, ok := taskClassRaw.(map[string]any)
if !ok {
return TaskClassUpsertInput{}, fmt.Errorf("task_class 参数类型非法,必须是对象")
}
// 允许顶层 items 覆盖 task_class.items便于 LLM 在生成参数时拆分表达。
if rawItems, exists := args["items"]; exists {
taskClassMap["items"] = rawItems
}
normalizeTaskClassPayload(taskClassMap)
rawJSON, err := json.Marshal(taskClassMap)
if err != nil {
return TaskClassUpsertInput{}, fmt.Errorf("task_class 参数序列化失败")
}
var request model.UserAddTaskClassRequest
if err := json.Unmarshal(rawJSON, &request); err != nil {
return TaskClassUpsertInput{}, fmt.Errorf("task_class 参数解析失败:%v", err)
}
source := ""
if rawSource, exists := args["source"]; exists {
if text, ok := rawSource.(string); ok {
source = strings.TrimSpace(text)
}
}
normalizeTaskClassSemanticRequest(&request)
return TaskClassUpsertInput{
ID: id,
Request: request,
Source: source,
}, nil
}
// normalizeTaskClassPayload 对 LLM 常见“近义字段/错层字段”做轻量兼容归一化。
//
// 职责边界:
// 1. 负责把已知等价字段映射到后端真实契约,减少“明明填了却校验失败”的误伤;
// 2. 不负责兜底补齐业务必填项(如 mode/config这些仍由校验层决定是否报错
// 3. 仅处理本工具已观测到的高频偏差,避免过度“自动纠错”掩盖真实输入问题。
func normalizeTaskClassPayload(taskClassMap map[string]any) {
if len(taskClassMap) == 0 {
return
}
// 1. 兼容日期字段错层:
// 1.1 若顶层 start_date/end_date 缺失;
// 1.2 且 config.start_date/config.end_date 有值;
// 1.3 则抬升到顶层,匹配 UserAddTaskClassRequest 契约。
configMap, _ := readAnyMap(taskClassMap["config"])
promoteStringField(taskClassMap, configMap, "start_date")
promoteStringField(taskClassMap, configMap, "end_date")
// 2. 兼容 items 的语义字段:
// 2.1 content 缺失时,尝试从 description/title/name 回填;
// 2.2 order 缺失或非法时,按当前顺序补 1..N
// 2.3 失败时不抛错,留给校验层输出明确问题。
normalizeTaskClassItems(taskClassMap)
}
func promoteStringField(top map[string]any, config map[string]any, key string) {
if top == nil {
return
}
if strings.TrimSpace(readAnyString(top[key])) != "" {
return
}
if strings.TrimSpace(readAnyString(config[key])) == "" {
return
}
top[key] = strings.TrimSpace(readAnyString(config[key]))
}
func normalizeTaskClassItems(taskClassMap map[string]any) {
rawItems, exists := taskClassMap["items"]
if !exists {
return
}
itemList, ok := rawItems.([]any)
if !ok {
return
}
for idx := range itemList {
itemMap, ok := itemList[idx].(map[string]any)
if !ok {
continue
}
if !hasPositiveInt(itemMap["order"]) {
itemMap["order"] = idx + 1
}
if strings.TrimSpace(readAnyString(itemMap["content"])) == "" {
content := firstNonEmptyString(
readAnyString(itemMap["content"]),
readAnyString(itemMap["description"]),
readAnyString(itemMap["title"]),
readAnyString(itemMap["name"]),
)
if strings.TrimSpace(content) != "" {
itemMap["content"] = strings.TrimSpace(content)
}
}
itemList[idx] = itemMap
}
taskClassMap["items"] = itemList
}
func readAnyMap(raw any) (map[string]any, bool) {
if raw == nil {
return nil, false
}
value, ok := raw.(map[string]any)
return value, ok
}
func readAnyString(raw any) string {
switch value := raw.(type) {
case string:
return value
default:
return ""
}
}
// normalizeTaskClassSemanticRequest 归一化任务类语义画像字段。
//
// 职责边界:
// 1. 负责把 LLM 或用户可能给出的中文/近义值收口成稳定枚举;
// 2. 不负责补默认值,字段缺失仍由上层决定是否接受;
// 3. 归一化失败时保留原值,交给校验层输出明确错误。
func normalizeTaskClassSemanticRequest(req *model.UserAddTaskClassRequest) {
if req == nil {
return
}
if normalized := normalizeSubjectType(req.SubjectType); normalized != "" {
req.SubjectType = normalized
}
if normalized := normalizeLevelValue(req.DifficultyLevel); normalized != "" {
req.DifficultyLevel = normalized
}
if normalized := normalizeLevelValue(req.CognitiveIntensity); normalized != "" {
req.CognitiveIntensity = normalized
}
}
func normalizeSubjectType(raw string) string {
value := strings.TrimSpace(strings.ToLower(raw))
switch value {
case "quantitative", "计算型", "计算", "理工", "理工型":
return "quantitative"
case "memory", "记忆型", "记忆", "背诵型", "背诵":
return "memory"
case "reading", "阅读型", "阅读":
return "reading"
case "mixed", "混合型", "混合":
return "mixed"
default:
return ""
}
}
func normalizeLevelValue(raw string) string {
value := strings.TrimSpace(strings.ToLower(raw))
switch value {
case "low", "低":
return "low"
case "medium", "中", "中等":
return "medium"
case "high", "高":
return "high"
default:
return ""
}
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func hasPositiveInt(raw any) bool {
switch value := raw.(type) {
case int:
return value > 0
case int8:
return value > 0
case int16:
return value > 0
case int32:
return value > 0
case int64:
return value > 0
case float32:
return int(value) > 0
case float64:
return int(value) > 0
default:
return false
}
}
func validateTaskClassUpsertRequest(req model.UserAddTaskClassRequest, id int) []string {
issues := make([]string, 0)
if id < 0 {
issues = append(issues, "id 不能小于 0")
}
if strings.TrimSpace(req.Name) == "" {
issues = append(issues, "name 不能为空")
}
mode := strings.TrimSpace(strings.ToLower(req.Mode))
if mode != "auto" && mode != "manual" {
issues = append(issues, "mode 仅支持 auto/manual")
}
if mode == "auto" {
if strings.TrimSpace(req.StartDate) == "" || strings.TrimSpace(req.EndDate) == "" {
issues = append(issues, "auto 模式必须提供 start_date/end_date")
} else {
startDate, err1 := time.ParseInLocation("2006-01-02", strings.TrimSpace(req.StartDate), time.Local)
endDate, err2 := time.ParseInLocation("2006-01-02", strings.TrimSpace(req.EndDate), time.Local)
if err1 != nil || err2 != nil {
issues = append(issues, "start_date/end_date 日期格式非法,需为 YYYY-MM-DD")
} else if startDate.After(endDate) {
issues = append(issues, "start_date 不能晚于 end_date")
}
}
}
if strings.TrimSpace(req.SubjectType) != "" && normalizeSubjectType(req.SubjectType) == "" {
issues = append(issues, "subject_type 仅支持 quantitative/memory/reading/mixed")
}
if strings.TrimSpace(req.DifficultyLevel) != "" && normalizeLevelValue(req.DifficultyLevel) == "" {
issues = append(issues, "difficulty_level 仅支持 low/medium/high")
}
if strings.TrimSpace(req.CognitiveIntensity) != "" && normalizeLevelValue(req.CognitiveIntensity) == "" {
issues = append(issues, "cognitive_intensity 仅支持 low/medium/high")
}
if strings.TrimSpace(req.SubjectType) == "" {
issues = append(issues, "subject_type 不能为空")
}
if strings.TrimSpace(req.DifficultyLevel) == "" {
issues = append(issues, "difficulty_level 不能为空")
}
if strings.TrimSpace(req.CognitiveIntensity) == "" {
issues = append(issues, "cognitive_intensity 不能为空")
}
if req.Config.TotalSlots <= 0 {
issues = append(issues, "config.total_slots 必须大于 0")
}
strategy := strings.TrimSpace(strings.ToLower(req.Config.Strategy))
if strategy != "steady" && strategy != "rapid" {
issues = append(issues, "config.strategy 仅支持 steady/rapid")
}
for _, section := range req.Config.ExcludedSlots {
// 1. excluded_slots 在粗排算法中按“半天块索引”解释,而不是原子节次;
// 2. 每个块固定映射 2 节1->1-22->3-4...6->11-12
// 3. 若放行 7~12会在 buildTimeGrid 扩展时生成 13~24 节,触发数组越界。
if section < 1 || section > 6 {
issues = append(issues, "config.excluded_slots 仅允许 1~6半天块索引每块=2节")
break
}
}
for _, dayOfWeek := range req.Config.ExcludedDaysOfWeek {
// 1. excluded_days_of_week 属于“整天不可排”的硬约束;
// 2. 仅允许 1~7对应周一到周日
// 3. 非法值会导致粗排过滤口径失真,因此统一在写入口拦截。
if dayOfWeek < 1 || dayOfWeek > 7 {
issues = append(issues, "config.excluded_days_of_week 仅允许 1~7周一到周日")
break
}
}
if len(req.Items) == 0 {
issues = append(issues, "items 不能为空")
}
for index, item := range req.Items {
if item.Order <= 0 {
issues = append(issues, fmt.Sprintf("items[%d].order 必须大于 0", index))
}
if strings.TrimSpace(item.Content) == "" {
issues = append(issues, fmt.Sprintf("items[%d].content 不能为空", index))
}
}
return uniqueTaskClassIssues(issues)
}
func uniqueTaskClassIssues(issues []string) []string {
if len(issues) == 0 {
return issues
}
seen := make(map[string]struct{}, len(issues))
out := make([]string, 0, len(issues))
for _, issue := range issues {
trimmed := strings.TrimSpace(issue)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
out = append(out, trimmed)
}
return out
}
func readUpsertUserID(raw any) (int, bool) {
return readUpsertInt(raw)
}
func readUpsertInt(raw any) (int, bool) {
switch value := raw.(type) {
case int:
return value, true
case int8:
return int(value), true
case int16:
return int(value), true
case int32:
return int(value), true
case int64:
return int(value), true
case float64:
return int(value), true
case float32:
return int(value), true
default:
return 0, false
}
}
func marshalTaskClassUpsertResult(result taskClassUpsertToolResult) string {
raw, err := json.Marshal(result)
if err != nil {
return `{"tool":"upsert_task_class","success":false,"error":"result encode failed","error_code":"encode_failed"}`
}
return string(raw)
}

View File

@@ -0,0 +1,252 @@
package newagenttools
import "strings"
const (
// ToolDomainSchedule 表示“排程调整”工具域。
ToolDomainSchedule = "schedule"
// ToolDomainTaskClass 表示“任务类定义”工具域。
ToolDomainTaskClass = "taskclass"
)
const (
// ToolNameContextToolsAdd 表示“向 msg0 动态区注入目标工具域定义”工具。
ToolNameContextToolsAdd = "context_tools_add"
// ToolNameContextToolsRemove 表示“从 msg0 动态区移除目标工具域定义”工具。
ToolNameContextToolsRemove = "context_tools_remove"
)
const (
// ToolPackCore 是固定包:始终注入,不允许显式 add/remove。
ToolPackCore = "core"
// schedule 二级包(可选)。
ToolPackQueue = "queue"
ToolPackMutation = "mutation"
ToolPackAnalyze = "analyze"
ToolPackDetailRead = "detail_read"
ToolPackDeepAnalyze = "deep_analyze"
ToolPackWeb = "web"
)
type toolProfile struct {
Domain string
Pack string
}
// toolProfileByName 维护“工具名 -> 域/二级包”映射。
//
// 设计说明:
// 1. context 管理工具不参与域/包映射;
// 2. schedule 的 core 包是固定注入;其余能力按二级包按需注入;
// 3. taskclass 目前只有 core 包(固定注入)。
var toolProfileByName = map[string]toolProfile{
"get_overview": {Domain: ToolDomainSchedule, Pack: ToolPackCore},
"query_available_slots": {Domain: ToolDomainSchedule, Pack: ToolPackCore},
"query_target_tasks": {Domain: ToolDomainSchedule, Pack: ToolPackCore},
"analyze_health": {Domain: ToolDomainSchedule, Pack: ToolPackAnalyze},
"query_range": {Domain: ToolDomainSchedule, Pack: ToolPackDetailRead},
"get_task_info": {Domain: ToolDomainSchedule, Pack: ToolPackDetailRead},
"queue_status": {Domain: ToolDomainSchedule, Pack: ToolPackQueue},
"queue_pop_head": {Domain: ToolDomainSchedule, Pack: ToolPackQueue},
"queue_apply_head_move": {Domain: ToolDomainSchedule, Pack: ToolPackQueue},
"queue_skip_head": {Domain: ToolDomainSchedule, Pack: ToolPackQueue},
"place": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
"move": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
"swap": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
"batch_move": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
"spread_even": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
"min_context_switch": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
"unplace": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
"analyze_rhythm": {Domain: ToolDomainSchedule, Pack: ToolPackDeepAnalyze},
"analyze_tolerance": {Domain: ToolDomainSchedule, Pack: ToolPackDeepAnalyze},
"web_search": {Domain: ToolDomainSchedule, Pack: ToolPackWeb},
"web_fetch": {Domain: ToolDomainSchedule, Pack: ToolPackWeb},
"upsert_task_class": {Domain: ToolDomainTaskClass, Pack: ToolPackCore},
}
// NormalizeToolDomain 统一规范化工具域字符串。
func NormalizeToolDomain(domain string) string {
switch strings.ToLower(strings.TrimSpace(domain)) {
case ToolDomainSchedule:
return ToolDomainSchedule
case ToolDomainTaskClass:
return ToolDomainTaskClass
default:
return ""
}
}
// IsSupportedToolDomain 判断是否为当前支持的业务工具域。
func IsSupportedToolDomain(domain string) bool {
return NormalizeToolDomain(domain) != ""
}
// NormalizeToolPack 统一规范化指定域下的二级包名。
func NormalizeToolPack(domain, pack string) string {
normalizedDomain := NormalizeToolDomain(domain)
normalizedPack := strings.ToLower(strings.TrimSpace(pack))
if normalizedDomain == "" || normalizedPack == "" {
return ""
}
switch normalizedDomain {
case ToolDomainSchedule:
switch normalizedPack {
case ToolPackCore, ToolPackQueue, ToolPackMutation, ToolPackAnalyze, ToolPackDetailRead, ToolPackDeepAnalyze, ToolPackWeb:
return normalizedPack
default:
return ""
}
case ToolDomainTaskClass:
if normalizedPack == ToolPackCore {
return ToolPackCore
}
return ""
default:
return ""
}
}
// IsSupportedToolPack 判断某域下某二级包是否受支持。
func IsSupportedToolPack(domain, pack string) bool {
return NormalizeToolPack(domain, pack) != ""
}
// IsFixedToolPack 判断某域下某二级包是否属于固定注入包。
func IsFixedToolPack(domain, pack string) bool {
normalizedPack := NormalizeToolPack(domain, pack)
return normalizedPack == ToolPackCore
}
// ListOptionalToolPacks 返回某域可选二级包列表(不含 core
func ListOptionalToolPacks(domain string) []string {
switch NormalizeToolDomain(domain) {
case ToolDomainSchedule:
return []string{
ToolPackMutation,
ToolPackAnalyze,
ToolPackDetailRead,
ToolPackDeepAnalyze,
ToolPackQueue,
ToolPackWeb,
}
default:
return nil
}
}
// ListDefaultToolPacks 返回某域“默认注入”的可选包集合。
//
// 说明:
// 1. 仅用于 packs 为空时的兜底,目的是降低 msg0 噪声;
// 2. schedule 默认只开 mutation+analyze其他包按需 add
// 3. taskclass 当前无可选包。
func ListDefaultToolPacks(domain string) []string {
switch NormalizeToolDomain(domain) {
case ToolDomainSchedule:
return []string{ToolPackMutation, ToolPackAnalyze}
default:
return nil
}
}
// NormalizeToolPacks 规范化 pack 列表,并去重。
//
// 1. 仅返回受支持的 pack
// 2. 自动剔除固定包 corecore 不接受显式管理);
// 3. 顺序保持第一次出现顺序,便于日志和 prompt 可读性。
func NormalizeToolPacks(domain string, packs []string) []string {
normalizedDomain := NormalizeToolDomain(domain)
if normalizedDomain == "" || len(packs) == 0 {
return nil
}
seen := make(map[string]struct{}, len(packs))
result := make([]string, 0, len(packs))
for _, rawPack := range packs {
pack := NormalizeToolPack(normalizedDomain, rawPack)
if pack == "" || IsFixedToolPack(normalizedDomain, pack) {
continue
}
if _, exists := seen[pack]; exists {
continue
}
seen[pack] = struct{}{}
result = append(result, pack)
}
if len(result) == 0 {
return nil
}
return result
}
// ResolveEffectiveToolPacks 返回某域下“真正生效”的可选包集合。
//
// 兼容策略:
// 1. schedule 域且 packs 为空时默认启用最小可用包mutation+analyze
// 2. taskclass 目前无可选包,统一返回 nil
// 3. 非法域统一返回 nil。
func ResolveEffectiveToolPacks(domain string, packs []string) []string {
normalizedDomain := NormalizeToolDomain(domain)
if normalizedDomain == "" {
return nil
}
if normalizedDomain == ToolDomainTaskClass {
return nil
}
normalizedPacks := NormalizeToolPacks(normalizedDomain, packs)
if len(normalizedPacks) > 0 {
return normalizedPacks
}
defaultPacks := ListDefaultToolPacks(normalizedDomain)
if len(defaultPacks) == 0 {
return nil
}
result := make([]string, len(defaultPacks))
copy(result, defaultPacks)
return result
}
// IsContextManagementTool 判断工具是否属于上下文管理工具。
func IsContextManagementTool(name string) bool {
switch strings.TrimSpace(name) {
case ToolNameContextToolsAdd, ToolNameContextToolsRemove:
return true
default:
return false
}
}
// ResolveToolDomain 返回工具所属业务域。
func ResolveToolDomain(name string) (string, bool) {
domain, _, ok := ResolveToolDomainPack(name)
return domain, ok
}
// ResolveToolDomainPack 返回工具所属域与二级包。
//
// 返回语义:
// 1. 命中映射返回 (domain, pack, true)
// 2. 未命中返回 ("", "", false)
// 3. context 管理工具统一返回 ("", "", false)。
func ResolveToolDomainPack(name string) (string, string, bool) {
toolName := strings.TrimSpace(name)
if IsContextManagementTool(toolName) {
return "", "", false
}
profile, ok := toolProfileByName[toolName]
if !ok {
return "", "", false
}
return profile.Domain, profile.Pack, true
}

View File

@@ -55,6 +55,22 @@ func (sv *TaskClassService) AddOrUpdateTaskClass(ctx context.Context, req *model
if req.Mode == "" || req.Name == "" || len(req.Items) == 0 {
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)