Version: 0.9.46.dev.260427
后端: 1. taskclass 执行闭环继续收紧——Plan / Execute 全面切到“最小工具闭环”视角,明确学习目标/总节数/禁排时段/排除星期默认停留 taskclass 域;未给日期范围时禁止擅自补 start_date/end_date,upsert_task_class 重试前先做写前检查并区分“内部表示修正”与“必须追问用户”的关键时间事实 2. QuickTask / TaskQuery 轻量链路继续收敛——新增 model/taskquery_contract.go 统一查询协议,QuickTaskDeps / start.go 改用 model 层参数;删除 query_tasks / quick_note_create 旧工具实现,避免任务查询与随口记再回流 execute 工具链 3. schedule 微调工具继续瘦身——下线 spread_even / min_context_switch 及其复合规划逻辑,清理 analyze_load / analyze_subjects / analyze_context / analyze_tolerance 等历史能力;execute 顺序策略收敛为局部 move / swap,提示词与工具目录仅暴露当前真实可用工具 4. 执行与时间线体验补齐——execute 为流式 speak 补发归一化尾部,避免 deliver 文案黏连;前端时间线新增 interrupt / status 协议识别、工具事件归并与状态过滤,减少 ToolTrace 重复和会话重建误判 前端: 5. AssistantPanel 适配新版 timeline extra 事件——schedule_agent.ts 补齐 interrupt / status kind,工具调用与结果按摘要/参数/工具名合并,恢复历史时不再把协议事件误判成用户消息
This commit is contained in:
@@ -288,7 +288,7 @@ func Start() {
|
||||
}
|
||||
return created.ID, nil
|
||||
},
|
||||
QueryTasks: func(ctx context.Context, userID int, params newagenttools.TaskQueryParams) ([]newagenttools.TaskQueryResult, error) {
|
||||
QueryTasks: func(ctx context.Context, userID int, params newagentmodel.TaskQueryParams) ([]newagentmodel.TaskQueryResult, error) {
|
||||
req := newagentmodel.TaskQueryRequest{
|
||||
UserID: userID,
|
||||
Quadrant: params.Quadrant,
|
||||
@@ -304,13 +304,13 @@ func Start() {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results := make([]newagenttools.TaskQueryResult, 0, len(records))
|
||||
results := make([]newagentmodel.TaskQueryResult, 0, len(records))
|
||||
for _, r := range records {
|
||||
deadlineStr := ""
|
||||
if r.DeadlineAt != nil {
|
||||
deadlineStr = r.DeadlineAt.In(time.Local).Format("2006-01-02 15:04")
|
||||
}
|
||||
results = append(results, newagenttools.TaskQueryResult{
|
||||
results = append(results, newagentmodel.TaskQueryResult{
|
||||
ID: r.ID,
|
||||
Title: r.Title,
|
||||
PriorityGroup: r.PriorityGroup,
|
||||
|
||||
@@ -1,473 +0,0 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RefineTaskCandidate 表示复合工具规划阶段可移动的任务候选。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只承载“任务当前坐标 + 规划所需标签”;
|
||||
// 2. 不承载冲突判断、窗口判断等执行期逻辑;
|
||||
// 3. 由调用方保证 task_item_id 唯一且为正数。
|
||||
type RefineTaskCandidate struct {
|
||||
TaskItemID int
|
||||
Week int
|
||||
DayOfWeek int
|
||||
SectionFrom int
|
||||
SectionTo int
|
||||
Name string
|
||||
ContextTag string
|
||||
OriginRank int
|
||||
}
|
||||
|
||||
// RefineSlotCandidate 表示复合工具可选落点(坑位)。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只描述可候选的时段坐标;
|
||||
// 2. 不描述“为什么可用”,可用性由调用方预先筛好;
|
||||
// 3. Span 由 SectionFrom/SectionTo 推导,不单独存储。
|
||||
type RefineSlotCandidate struct {
|
||||
Week int
|
||||
DayOfWeek int
|
||||
SectionFrom int
|
||||
SectionTo int
|
||||
}
|
||||
|
||||
// RefineMovePlanItem 表示“任务 -> 目标坑位”的确定性规划结果。
|
||||
type RefineMovePlanItem struct {
|
||||
TaskItemID int
|
||||
ToWeek int
|
||||
ToDay int
|
||||
ToSectionFrom int
|
||||
ToSectionTo int
|
||||
}
|
||||
|
||||
// RefineCompositePlanOptions 是复合规划器的可选辅助输入。
|
||||
//
|
||||
// 说明:
|
||||
// 1. ExistingDayLoad 用于提供“目标范围内的既有负载基线”,用于均匀铺开打分;
|
||||
// 2. key 约定为 "week-day",例如 "16-3";
|
||||
// 3. 未提供时,规划器按 0 基线处理。
|
||||
type RefineCompositePlanOptions struct {
|
||||
ExistingDayLoad map[string]int
|
||||
}
|
||||
|
||||
// PlanEvenSpreadMoves 规划“均匀铺开”的确定性移动方案。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先按稳定顺序归一化任务与坑位,保证同输入必同输出;
|
||||
// 2. 逐任务选择“投放后日负载最小”的坑位,主目标是降低日负载离散度;
|
||||
// 3. 同分时按时间更早优先,进一步保证确定性;
|
||||
// 4. 若某任务不存在同跨度坑位,直接失败并返回明确错误。
|
||||
func PlanEvenSpreadMoves(tasks []RefineTaskCandidate, slots []RefineSlotCandidate, options RefineCompositePlanOptions) ([]RefineMovePlanItem, error) {
|
||||
normalizedTasks, err := normalizeRefineTasks(tasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalizedSlots, err := normalizeRefineSlots(slots)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(normalizedSlots) < len(normalizedTasks) {
|
||||
return nil, fmt.Errorf("可用坑位不足:tasks=%d, slots=%d", len(normalizedTasks), len(normalizedSlots))
|
||||
}
|
||||
|
||||
// 1. dayLoad 记录“当前已占 + 本次规划已分配”的日负载。
|
||||
// 2. 这里先写入调用方提供的既有基线,再在循环中动态递增。
|
||||
dayLoad := make(map[string]int, len(options.ExistingDayLoad)+len(normalizedSlots))
|
||||
for key, value := range options.ExistingDayLoad {
|
||||
if value <= 0 {
|
||||
continue
|
||||
}
|
||||
dayLoad[strings.TrimSpace(key)] = value
|
||||
}
|
||||
|
||||
used := make([]bool, len(normalizedSlots))
|
||||
moves := make([]RefineMovePlanItem, 0, len(normalizedTasks))
|
||||
selectedSlots := make([]RefineSlotCandidate, 0, len(normalizedTasks))
|
||||
|
||||
for _, task := range normalizedTasks {
|
||||
taskSpan := sectionSpan(task.SectionFrom, task.SectionTo)
|
||||
bestIdx := -1
|
||||
bestScore := int(^uint(0) >> 1) // max int
|
||||
|
||||
for idx, slot := range normalizedSlots {
|
||||
if used[idx] {
|
||||
continue
|
||||
}
|
||||
if sectionSpan(slot.SectionFrom, slot.SectionTo) != taskSpan {
|
||||
continue
|
||||
}
|
||||
if slotOverlapsAny(slot, selectedSlots) {
|
||||
continue
|
||||
}
|
||||
dayKey := composeDayKey(slot.Week, slot.DayOfWeek)
|
||||
projectedLoad := dayLoad[dayKey] + 1
|
||||
// 1. projectedLoad 是主目标(越小越均衡);
|
||||
// 2. idx 是次级目标(越早的坑位越优先,保证稳定)。
|
||||
score := projectedLoad*10000 + idx
|
||||
if score < bestScore {
|
||||
bestScore = score
|
||||
bestIdx = idx
|
||||
}
|
||||
}
|
||||
if bestIdx < 0 {
|
||||
return nil, fmt.Errorf("任务 id=%d 无可用同跨度坑位", task.TaskItemID)
|
||||
}
|
||||
|
||||
chosen := normalizedSlots[bestIdx]
|
||||
used[bestIdx] = true
|
||||
selectedSlots = append(selectedSlots, chosen)
|
||||
dayLoad[composeDayKey(chosen.Week, chosen.DayOfWeek)]++
|
||||
moves = append(moves, RefineMovePlanItem{
|
||||
TaskItemID: task.TaskItemID,
|
||||
ToWeek: chosen.Week,
|
||||
ToDay: chosen.DayOfWeek,
|
||||
ToSectionFrom: chosen.SectionFrom,
|
||||
ToSectionTo: chosen.SectionTo,
|
||||
})
|
||||
}
|
||||
return moves, nil
|
||||
}
|
||||
|
||||
// PlanMinContextSwitchMoves 规划“同科目上下文切换最少”的确定性移动方案。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先把任务按 context_tag 分组,目标是让同组任务尽量连续;
|
||||
// 2. 分组顺序按“组大小降序 + 最早 origin_rank + 标签字典序”稳定排序;
|
||||
// 3. 组内按任务稳定顺序排,再顺序填入时间上最早可用同跨度坑位;
|
||||
// 4. 若某任务不存在同跨度坑位,立即失败并返回明确错误。
|
||||
func PlanMinContextSwitchMoves(tasks []RefineTaskCandidate, slots []RefineSlotCandidate, _ RefineCompositePlanOptions) ([]RefineMovePlanItem, error) {
|
||||
normalizedTasks, err := normalizeRefineTasks(tasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalizedSlots, err := normalizeRefineSlots(slots)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(normalizedSlots) < len(normalizedTasks) {
|
||||
return nil, fmt.Errorf("可用坑位不足:tasks=%d, slots=%d", len(normalizedTasks), len(normalizedSlots))
|
||||
}
|
||||
|
||||
type taskGroup struct {
|
||||
ContextKey string
|
||||
Tasks []RefineTaskCandidate
|
||||
MinRank int
|
||||
}
|
||||
groupingKeys := buildMinContextGroupingKeys(normalizedTasks)
|
||||
groupMap := make(map[string]*taskGroup)
|
||||
groupOrder := make([]string, 0, len(normalizedTasks))
|
||||
|
||||
for _, task := range normalizedTasks {
|
||||
key := groupingKeys[task.TaskItemID]
|
||||
group, exists := groupMap[key]
|
||||
if !exists {
|
||||
group = &taskGroup{
|
||||
ContextKey: key,
|
||||
MinRank: normalizedOriginRank(task),
|
||||
}
|
||||
groupMap[key] = group
|
||||
groupOrder = append(groupOrder, key)
|
||||
}
|
||||
group.Tasks = append(group.Tasks, task)
|
||||
if rank := normalizedOriginRank(task); rank < group.MinRank {
|
||||
group.MinRank = rank
|
||||
}
|
||||
}
|
||||
|
||||
groups := make([]taskGroup, 0, len(groupMap))
|
||||
for _, key := range groupOrder {
|
||||
group := groupMap[key]
|
||||
sort.SliceStable(group.Tasks, func(i, j int) bool {
|
||||
return compareTaskOrder(group.Tasks[i], group.Tasks[j]) < 0
|
||||
})
|
||||
groups = append(groups, *group)
|
||||
}
|
||||
sort.SliceStable(groups, func(i, j int) bool {
|
||||
if len(groups[i].Tasks) != len(groups[j].Tasks) {
|
||||
return len(groups[i].Tasks) > len(groups[j].Tasks)
|
||||
}
|
||||
if groups[i].MinRank != groups[j].MinRank {
|
||||
return groups[i].MinRank < groups[j].MinRank
|
||||
}
|
||||
return groups[i].ContextKey < groups[j].ContextKey
|
||||
})
|
||||
|
||||
orderedTasks := make([]RefineTaskCandidate, 0, len(normalizedTasks))
|
||||
for _, group := range groups {
|
||||
orderedTasks = append(orderedTasks, group.Tasks...)
|
||||
}
|
||||
|
||||
used := make([]bool, len(normalizedSlots))
|
||||
moves := make([]RefineMovePlanItem, 0, len(orderedTasks))
|
||||
selectedSlots := make([]RefineSlotCandidate, 0, len(orderedTasks))
|
||||
for _, task := range orderedTasks {
|
||||
taskSpan := sectionSpan(task.SectionFrom, task.SectionTo)
|
||||
chosenIdx := -1
|
||||
for idx, slot := range normalizedSlots {
|
||||
if used[idx] {
|
||||
continue
|
||||
}
|
||||
if sectionSpan(slot.SectionFrom, slot.SectionTo) != taskSpan {
|
||||
continue
|
||||
}
|
||||
if slotOverlapsAny(slot, selectedSlots) {
|
||||
continue
|
||||
}
|
||||
chosenIdx = idx
|
||||
break
|
||||
}
|
||||
if chosenIdx < 0 {
|
||||
return nil, fmt.Errorf("任务 id=%d 无可用同跨度坑位", task.TaskItemID)
|
||||
}
|
||||
chosen := normalizedSlots[chosenIdx]
|
||||
used[chosenIdx] = true
|
||||
selectedSlots = append(selectedSlots, chosen)
|
||||
moves = append(moves, RefineMovePlanItem{
|
||||
TaskItemID: task.TaskItemID,
|
||||
ToWeek: chosen.Week,
|
||||
ToDay: chosen.DayOfWeek,
|
||||
ToSectionFrom: chosen.SectionFrom,
|
||||
ToSectionTo: chosen.SectionTo,
|
||||
})
|
||||
}
|
||||
return moves, nil
|
||||
}
|
||||
|
||||
func normalizeRefineTasks(tasks []RefineTaskCandidate) ([]RefineTaskCandidate, error) {
|
||||
if len(tasks) == 0 {
|
||||
return nil, fmt.Errorf("任务列表为空")
|
||||
}
|
||||
normalized := make([]RefineTaskCandidate, 0, len(tasks))
|
||||
seen := make(map[int]struct{}, len(tasks))
|
||||
for _, task := range tasks {
|
||||
if task.TaskItemID <= 0 {
|
||||
return nil, fmt.Errorf("存在非法 task_item_id=%d", task.TaskItemID)
|
||||
}
|
||||
if _, exists := seen[task.TaskItemID]; exists {
|
||||
return nil, fmt.Errorf("任务 id=%d 重复", task.TaskItemID)
|
||||
}
|
||||
if !isValidDay(task.DayOfWeek) {
|
||||
return nil, fmt.Errorf("任务 id=%d day_of_week 非法=%d", task.TaskItemID, task.DayOfWeek)
|
||||
}
|
||||
if !isValidSection(task.SectionFrom, task.SectionTo) {
|
||||
return nil, fmt.Errorf("任务 id=%d 节次区间非法=%d-%d", task.TaskItemID, task.SectionFrom, task.SectionTo)
|
||||
}
|
||||
seen[task.TaskItemID] = struct{}{}
|
||||
normalized = append(normalized, task)
|
||||
}
|
||||
sort.SliceStable(normalized, func(i, j int) bool {
|
||||
return compareTaskOrder(normalized[i], normalized[j]) < 0
|
||||
})
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeRefineSlots(slots []RefineSlotCandidate) ([]RefineSlotCandidate, error) {
|
||||
if len(slots) == 0 {
|
||||
return nil, fmt.Errorf("可用坑位为空")
|
||||
}
|
||||
normalized := make([]RefineSlotCandidate, 0, len(slots))
|
||||
seen := make(map[string]struct{}, len(slots))
|
||||
for _, slot := range slots {
|
||||
if slot.Week <= 0 {
|
||||
return nil, fmt.Errorf("存在非法 week=%d", slot.Week)
|
||||
}
|
||||
if !isValidDay(slot.DayOfWeek) {
|
||||
return nil, fmt.Errorf("存在非法 day_of_week=%d", slot.DayOfWeek)
|
||||
}
|
||||
if !isValidSection(slot.SectionFrom, slot.SectionTo) {
|
||||
return nil, fmt.Errorf("存在非法节次区间=%d-%d", slot.SectionFrom, slot.SectionTo)
|
||||
}
|
||||
key := fmt.Sprintf("%d-%d-%d-%d", slot.Week, slot.DayOfWeek, slot.SectionFrom, slot.SectionTo)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
normalized = append(normalized, slot)
|
||||
}
|
||||
sort.SliceStable(normalized, func(i, j int) bool {
|
||||
if normalized[i].Week != normalized[j].Week {
|
||||
return normalized[i].Week < normalized[j].Week
|
||||
}
|
||||
if normalized[i].DayOfWeek != normalized[j].DayOfWeek {
|
||||
return normalized[i].DayOfWeek < normalized[j].DayOfWeek
|
||||
}
|
||||
if normalized[i].SectionFrom != normalized[j].SectionFrom {
|
||||
return normalized[i].SectionFrom < normalized[j].SectionFrom
|
||||
}
|
||||
return normalized[i].SectionTo < normalized[j].SectionTo
|
||||
})
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func compareTaskOrder(a, b RefineTaskCandidate) int {
|
||||
rankA := normalizedOriginRank(a)
|
||||
rankB := normalizedOriginRank(b)
|
||||
if rankA != rankB {
|
||||
return rankA - rankB
|
||||
}
|
||||
if a.Week != b.Week {
|
||||
return a.Week - b.Week
|
||||
}
|
||||
if a.DayOfWeek != b.DayOfWeek {
|
||||
return a.DayOfWeek - b.DayOfWeek
|
||||
}
|
||||
if a.SectionFrom != b.SectionFrom {
|
||||
return a.SectionFrom - b.SectionFrom
|
||||
}
|
||||
if a.SectionTo != b.SectionTo {
|
||||
return a.SectionTo - b.SectionTo
|
||||
}
|
||||
return a.TaskItemID - b.TaskItemID
|
||||
}
|
||||
|
||||
func normalizedOriginRank(task RefineTaskCandidate) int {
|
||||
if task.OriginRank > 0 {
|
||||
return task.OriginRank
|
||||
}
|
||||
// 1. 无 origin_rank 时回退到较大稳定值,避免把“未知顺序”抢到前面。
|
||||
// 2. 叠加 task_id 作为细粒度稳定因子,保证排序可复现。
|
||||
return 1_000_000 + task.TaskItemID
|
||||
}
|
||||
|
||||
func normalizeContextKey(tag string) string {
|
||||
text := strings.TrimSpace(tag)
|
||||
if text == "" {
|
||||
return "General"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// buildMinContextGroupingKeys 为 MinContextSwitch 生成“实际用于聚类”的分组键。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先优先使用现有 ContextTag,避免影响已稳定的显式标签链路;
|
||||
// 2. 若整批任务只剩一个粗粒度标签(例如全是 General/High-Logic),说明标签对“同科目连续”帮助不足;
|
||||
// 3. 此时再基于任务名做学科关键词兜底,只在确实能拉开分组时启用;
|
||||
// 4. 若任务名也无法识别,则继续回落到原 ContextTag,保证行为可预测。
|
||||
func buildMinContextGroupingKeys(tasks []RefineTaskCandidate) map[int]string {
|
||||
keys := make(map[int]string, len(tasks))
|
||||
distinctExplicit := make(map[string]struct{}, len(tasks))
|
||||
distinctNonCoarse := make(map[string]struct{}, len(tasks))
|
||||
|
||||
for _, task := range tasks {
|
||||
key := normalizeContextKey(task.ContextTag)
|
||||
keys[task.TaskItemID] = key
|
||||
distinctExplicit[key] = struct{}{}
|
||||
if !isCoarseContextKey(key) {
|
||||
distinctNonCoarse[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 当显式标签已经至少区分出两类“非粗标签”时,直接尊重上游语义;
|
||||
// 2. 避免把已稳定的 context_tag 分组再改写成名称启发式结果。
|
||||
if len(distinctNonCoarse) >= 2 {
|
||||
return keys
|
||||
}
|
||||
// 1. 若显式标签本来就有 2 类及以上,且不全是粗标签,也继续沿用;
|
||||
// 2. 只有“整批退化到同一个粗标签”时,才值得尝试名称兜底。
|
||||
if len(distinctExplicit) > 1 && len(distinctNonCoarse) > 0 {
|
||||
return keys
|
||||
}
|
||||
|
||||
inferredKeys := make(map[int]string, len(tasks))
|
||||
distinctInferred := make(map[string]struct{}, len(tasks))
|
||||
for _, task := range tasks {
|
||||
inferred := inferSubjectContextKeyFromTaskName(task.Name)
|
||||
if inferred == "" {
|
||||
inferred = keys[task.TaskItemID]
|
||||
}
|
||||
inferredKeys[task.TaskItemID] = inferred
|
||||
distinctInferred[inferred] = struct{}{}
|
||||
}
|
||||
if len(distinctInferred) >= 2 {
|
||||
return inferredKeys
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func isCoarseContextKey(key string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(key)) {
|
||||
case "", "general", "high-logic", "high_logic", "memory", "review":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func inferSubjectContextKeyFromTaskName(name string) string {
|
||||
text := strings.ToLower(strings.TrimSpace(name))
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
subjectKeywordGroups := []struct {
|
||||
keywords []string
|
||||
groupKey string
|
||||
}{
|
||||
{
|
||||
keywords: []string{
|
||||
"概率", "随机事件", "随机变量", "条件概率", "全概率", "贝叶斯",
|
||||
"分布", "大数定律", "中心极限定理", "参数估计", "期望", "方差", "协方差", "相关系数",
|
||||
},
|
||||
groupKey: "subject:probability",
|
||||
},
|
||||
{
|
||||
keywords: []string{
|
||||
"数制", "码制", "逻辑代数", "逻辑函数", "卡诺图", "译码器", "编码器",
|
||||
"数据选择器", "触发器", "时序电路", "状态图", "状态化简", "计数器", "寄存器", "数电",
|
||||
},
|
||||
groupKey: "subject:digital_logic",
|
||||
},
|
||||
{
|
||||
keywords: []string{
|
||||
"命题逻辑", "谓词逻辑", "量词", "等值演算", "集合", "关系", "函数",
|
||||
"图论", "欧拉回路", "哈密顿", "生成树", "离散", "组合数学", "容斥", "递推",
|
||||
},
|
||||
groupKey: "subject:discrete_math",
|
||||
},
|
||||
}
|
||||
for _, group := range subjectKeywordGroups {
|
||||
for _, keyword := range group.keywords {
|
||||
if strings.Contains(text, keyword) {
|
||||
return group.groupKey
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func composeDayKey(week, day int) string {
|
||||
return fmt.Sprintf("%d-%d", week, day)
|
||||
}
|
||||
|
||||
func sectionSpan(from, to int) int {
|
||||
return to - from + 1
|
||||
}
|
||||
|
||||
func isValidDay(day int) bool {
|
||||
return day >= 1 && day <= 7
|
||||
}
|
||||
|
||||
func isValidSection(from, to int) bool {
|
||||
if from < 1 || to > 12 {
|
||||
return false
|
||||
}
|
||||
return from <= to
|
||||
}
|
||||
|
||||
func slotOverlapsAny(candidate RefineSlotCandidate, selected []RefineSlotCandidate) bool {
|
||||
for _, current := range selected {
|
||||
if current.Week != candidate.Week || current.DayOfWeek != candidate.DayOfWeek {
|
||||
continue
|
||||
}
|
||||
if current.SectionFrom <= candidate.SectionTo && candidate.SectionFrom <= current.SectionTo {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPlanEvenSpreadMovesPrefersLowerLoadDay(t *testing.T) {
|
||||
tasks := []RefineTaskCandidate{
|
||||
{TaskItemID: 101, Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, OriginRank: 1},
|
||||
{TaskItemID: 102, Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, OriginRank: 2},
|
||||
}
|
||||
slots := []RefineSlotCandidate{
|
||||
{Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2},
|
||||
{Week: 12, DayOfWeek: 2, SectionFrom: 1, SectionTo: 2},
|
||||
{Week: 12, DayOfWeek: 3, SectionFrom: 1, SectionTo: 2},
|
||||
}
|
||||
moves, err := PlanEvenSpreadMoves(tasks, slots, RefineCompositePlanOptions{
|
||||
ExistingDayLoad: map[string]int{
|
||||
composeDayKey(12, 1): 5,
|
||||
composeDayKey(12, 2): 1,
|
||||
composeDayKey(12, 3): 0,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PlanEvenSpreadMoves 返回错误: %v", err)
|
||||
}
|
||||
if len(moves) != 2 {
|
||||
t.Fatalf("期望移动 2 条,实际=%d", len(moves))
|
||||
}
|
||||
|
||||
// 1. 低负载日(周三)应优先被填充;
|
||||
// 2. 第二条应落在次低负载日(周二),而不是高负载日(周一)。
|
||||
weekDayByID := make(map[int][2]int, len(moves))
|
||||
for _, move := range moves {
|
||||
weekDayByID[move.TaskItemID] = [2]int{move.ToWeek, move.ToDay}
|
||||
}
|
||||
if got := weekDayByID[101]; got != [2]int{12, 3} {
|
||||
t.Fatalf("任务101应优先落到 W12D3,实际=%v", got)
|
||||
}
|
||||
if got := weekDayByID[102]; got != [2]int{12, 2} {
|
||||
t.Fatalf("任务102应落到 W12D2,实际=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanMinContextSwitchMovesGroupsSameContext(t *testing.T) {
|
||||
tasks := []RefineTaskCandidate{
|
||||
{TaskItemID: 201, Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, ContextTag: "数学", OriginRank: 1},
|
||||
{TaskItemID: 202, Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, ContextTag: "算法", OriginRank: 2},
|
||||
{TaskItemID: 203, Week: 16, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, ContextTag: "数学", OriginRank: 3},
|
||||
}
|
||||
slots := []RefineSlotCandidate{
|
||||
{Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2},
|
||||
{Week: 12, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4},
|
||||
{Week: 12, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6},
|
||||
}
|
||||
moves, err := PlanMinContextSwitchMoves(tasks, slots, RefineCompositePlanOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("PlanMinContextSwitchMoves 返回错误: %v", err)
|
||||
}
|
||||
if len(moves) != 3 {
|
||||
t.Fatalf("期望移动 3 条,实际=%d", len(moves))
|
||||
}
|
||||
|
||||
// 1. “数学”有 2 条,分组后应先连续落在最早两个坑位;
|
||||
// 2. 因此 201 与 203 对应的目标节次应是 1-2 与 3-4(顺序由 origin_rank 决定)。
|
||||
sort.SliceStable(moves, func(i, j int) bool {
|
||||
if moves[i].ToWeek != moves[j].ToWeek {
|
||||
return moves[i].ToWeek < moves[j].ToWeek
|
||||
}
|
||||
if moves[i].ToDay != moves[j].ToDay {
|
||||
return moves[i].ToDay < moves[j].ToDay
|
||||
}
|
||||
return moves[i].ToSectionFrom < moves[j].ToSectionFrom
|
||||
})
|
||||
if moves[0].TaskItemID != 201 || moves[1].TaskItemID != 203 {
|
||||
t.Fatalf("期望前两个坑位由同上下文任务占据,实际=%+v", moves)
|
||||
}
|
||||
if moves[2].TaskItemID != 202 {
|
||||
t.Fatalf("期望最后一个坑位为算法任务,实际=%+v", moves[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanMinContextSwitchMovesFallsBackToTaskNameWhenAllGeneral(t *testing.T) {
|
||||
tasks := []RefineTaskCandidate{
|
||||
{TaskItemID: 301, Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, Name: "随机事件与概率基础概念复习", ContextTag: "General", OriginRank: 1},
|
||||
{TaskItemID: 302, Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, Name: "数制、码制与逻辑代数基础", ContextTag: "General", OriginRank: 2},
|
||||
{TaskItemID: 303, Week: 16, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, Name: "第二章 条件概率与全概率公式", ContextTag: "General", OriginRank: 3},
|
||||
}
|
||||
slots := []RefineSlotCandidate{
|
||||
{Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2},
|
||||
{Week: 12, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4},
|
||||
{Week: 12, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6},
|
||||
}
|
||||
moves, err := PlanMinContextSwitchMoves(tasks, slots, RefineCompositePlanOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("PlanMinContextSwitchMoves 返回错误: %v", err)
|
||||
}
|
||||
if len(moves) != 3 {
|
||||
t.Fatalf("期望移动 3 条,实际=%d", len(moves))
|
||||
}
|
||||
|
||||
sort.SliceStable(moves, func(i, j int) bool {
|
||||
if moves[i].ToWeek != moves[j].ToWeek {
|
||||
return moves[i].ToWeek < moves[j].ToWeek
|
||||
}
|
||||
if moves[i].ToDay != moves[j].ToDay {
|
||||
return moves[i].ToDay < moves[j].ToDay
|
||||
}
|
||||
return moves[i].ToSectionFrom < moves[j].ToSectionFrom
|
||||
})
|
||||
if moves[0].TaskItemID != 301 || moves[1].TaskItemID != 303 {
|
||||
t.Fatalf("期望概率任务通过名称兜底连续聚类,实际=%+v", moves)
|
||||
}
|
||||
if moves[2].TaskItemID != 302 {
|
||||
t.Fatalf("期望数电任务落在最后一个坑位,实际=%+v", moves[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanEvenSpreadMovesReturnsErrorWhenSpanNotMatched(t *testing.T) {
|
||||
tasks := []RefineTaskCandidate{
|
||||
{TaskItemID: 301, Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 3, OriginRank: 1}, // span=3
|
||||
}
|
||||
slots := []RefineSlotCandidate{
|
||||
{Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2}, // span=2
|
||||
}
|
||||
_, err := PlanEvenSpreadMoves(tasks, slots, RefineCompositePlanOptions{})
|
||||
if err == nil {
|
||||
t.Fatalf("期望 span 不匹配时报错,实际 err=nil")
|
||||
}
|
||||
}
|
||||
@@ -173,7 +173,7 @@ func SmartPlanningMainLogic(schedules []model.Schedule, taskClass *model.TaskCla
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
//3.把这些时间通过DTO函数回填到涉<EFBFBD><EFBFBD>周的 UserWeekSchedule 结构中,供前端展示
|
||||
// 3. 把这些时间通过 DTO 函数回填到涉及周的 UserWeekSchedule 结构中,供前端展示。
|
||||
return conv.PlanningResultToUserWeekSchedules(schedules, allocatedItems), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -189,7 +189,7 @@ type CommonState struct {
|
||||
// HasScheduleWriteOps 标记本轮 execute 循环是否执行过日程写工具。
|
||||
// 调用目的:为 prompt/收口层提供“本轮是否真的动过日程写工具”的运行态信号。
|
||||
HasScheduleWriteOps bool `json:"has_schedule_write_ops,omitempty"`
|
||||
// UsedQuickNote 标记本轮是否调用过 quick_note_create 工具。
|
||||
// UsedQuickNote 标记本轮是否走过“快捷随口记任务”路径。
|
||||
// 调用目的:graph 完成后据此决定是否跳过记忆抽取,避免随口记内容被错误归类。
|
||||
UsedQuickNote bool `json:"used_quick_note,omitempty"`
|
||||
// HasScheduleChanges 标记本轮流程是否产生过日程变更(粗排或写工具)。
|
||||
|
||||
@@ -105,12 +105,12 @@ type AgentGraphDeps struct {
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. QuickTask 节点直接调这些函数,不经过 ToolRegistry,不走 ReAct 循环;
|
||||
// 2. CreateTask 和 QueryTasks 的签名与 tools 包的 QuickNoteDeps / TaskQueryDeps 一致。
|
||||
// 2. 这里只保留“创建任务 / 查询任务”两类轻量能力,避免再回退到已下线的孤立工具链。
|
||||
type QuickTaskDeps struct {
|
||||
// CreateTask 创建一条四象限任务,返回 task_id。
|
||||
CreateTask func(userID int, title string, priorityGroup int, deadlineAt *time.Time, urgencyThresholdAt *time.Time) (taskID int, err error)
|
||||
// QueryTasks 按条件查询用户任务列表。
|
||||
QueryTasks func(ctx context.Context, userID int, params newagenttools.TaskQueryParams) ([]newagenttools.TaskQueryResult, error)
|
||||
QueryTasks func(ctx context.Context, userID int, params TaskQueryParams) ([]TaskQueryResult, error)
|
||||
}
|
||||
|
||||
// --- 记忆 pinned block 常量(供 agentsvc 和 node 层共享) ---
|
||||
|
||||
35
backend/newAgent/model/taskquery_contract.go
Normal file
35
backend/newAgent/model/taskquery_contract.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// TaskQueryParams 描述快捷任务查询路径传给业务层的内部查询参数。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只承载“查询条件”本身,不负责 args 解析、默认值填充和错误提示;
|
||||
// 2. 所有字段均为轻量筛选语义,便于 quick_task 节点和 service 层直接复用;
|
||||
// 3. 不承担 LLM 工具协议,因为 query_tasks 工具链已下线。
|
||||
type TaskQueryParams struct {
|
||||
Quadrant *int
|
||||
SortBy string // deadline | priority | id
|
||||
Order string // asc | desc
|
||||
Limit int
|
||||
IncludeCompleted bool
|
||||
Keyword string
|
||||
DeadlineBefore *time.Time
|
||||
DeadlineAfter *time.Time
|
||||
}
|
||||
|
||||
// TaskQueryResult 描述快捷任务查询返回给上层的轻量任务视图。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只保留展示所需字段,避免把底层任务模型直接暴露给 newAgent 节点;
|
||||
// 2. 结果既可用于 quick_task 节点文本回复,也可供 service 装配其他轻量输出;
|
||||
// 3. 不负责序列化策略和文案渲染。
|
||||
type TaskQueryResult struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PriorityGroup int `json:"priority_group"`
|
||||
PriorityLabel string `json:"priority_label"`
|
||||
IsCompleted bool `json:"is_completed"`
|
||||
DeadlineAt string `json:"deadline_at,omitempty"`
|
||||
}
|
||||
@@ -27,7 +27,6 @@ const (
|
||||
executeStatusBlockID = "execute.status"
|
||||
executeSpeakBlockID = "execute.speak"
|
||||
executePinnedKey = "execution_context"
|
||||
toolMinContextSwitch = "min_context_switch"
|
||||
toolAnalyzeHealth = "analyze_health"
|
||||
executeHistoryKindKey = "newagent_history_kind"
|
||||
executeHistoryKindStepAdvanced = "execute_step_advanced"
|
||||
@@ -419,7 +418,7 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
||||
decision.Speak = normalizeSpeak(decision.Speak) // 末尾已含 \n
|
||||
|
||||
// 非写工具的 confirm 动作自动降级为 continue。
|
||||
// 调用目的:quick_note_create 等非写工具不应走确认卡片流程;
|
||||
// 调用目的:快捷随口记这类非日程写工具不应走确认卡片流程;
|
||||
// 即使 LLM 误输出 action=confirm,也在此处强制修正,
|
||||
// 确保 speak 正常推流和持久化,不会因 confirm 卡片跳过 persistVisibleAssistantMessage。
|
||||
if decision.Action == newagentmodel.ExecuteActionConfirm &&
|
||||
@@ -454,6 +453,25 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
||||
firstChunk = false
|
||||
}
|
||||
|
||||
// 1. execute 正文若已经在流式阶段推给前端,normalizeSpeak 新补出来的尾部(最常见是末尾 \n)
|
||||
// 不会自动回流到前端,只会留在 history / persist 中。
|
||||
// 2. 这会导致下一跳 deliver 首条正文直接接在 execute 最后一段后面,前端表现成两段文本黏连。
|
||||
// 3. 这里只补发“归一化后新增的尾巴”,不重发整段正文,也不改写中间内容,避免误伤已有流式体验。
|
||||
if speakStreamed {
|
||||
streamedText := fullText.String()
|
||||
if tail := buildExecuteNormalizedSpeakTail(streamedText, decision.Speak); tail != "" {
|
||||
if emitErr := emitter.EmitAssistantText(
|
||||
executeSpeakBlockID,
|
||||
executeStageName,
|
||||
tail,
|
||||
firstChunk,
|
||||
); emitErr != nil {
|
||||
return fmt.Errorf("执行文案尾部补发失败: %w", emitErr)
|
||||
}
|
||||
firstChunk = false
|
||||
}
|
||||
}
|
||||
|
||||
// 自省校验(仅 Plan 模式):next_plan / done 必须附带 goal_check,否则不推进,追加修正让 LLM 重试。
|
||||
//
|
||||
// 1. ReAct(无预定义步骤)下不强制 goal_check,避免 done 被错误拦截后进入循环;
|
||||
@@ -514,7 +532,7 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
||||
// 继续当前步骤的 ReAct 循环。
|
||||
// 若有工具调用意图,则执行工具并记录证据。
|
||||
if decision.ToolCall != nil {
|
||||
// 1. 写工具必须走 confirm;continue 只允许读工具。
|
||||
// 1. 所有写工具都必须走 confirm;continue 只允许读工具。
|
||||
// 2. 若模型误输出 continue+写工具,这里先做纠偏,不直接执行写操作。
|
||||
if input.ToolRegistry != nil && input.ToolRegistry.IsWriteTool(decision.ToolCall.Name) {
|
||||
flowState.ConsecutiveCorrections++
|
||||
@@ -533,7 +551,7 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
||||
executeStatusBlockID,
|
||||
executeStageName,
|
||||
"executing",
|
||||
fmt.Sprintf("执行校验:写工具 %q 未执行。原因:模型输出了 action=continue;日程修改工具必须使用 action=confirm。", strings.TrimSpace(decision.ToolCall.Name)),
|
||||
fmt.Sprintf("执行校验:写工具 %q 未执行。原因:模型输出了 action=continue;所有写工具都必须使用 action=confirm。", strings.TrimSpace(decision.ToolCall.Name)),
|
||||
false,
|
||||
)
|
||||
llmOutput := decision.Speak
|
||||
@@ -544,7 +562,7 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
||||
conversationContext,
|
||||
llmOutput,
|
||||
fmt.Sprintf("你输出了 action=continue,但工具 %q 属于写操作。", decision.ToolCall.Name),
|
||||
"写操作必须输出 action=confirm,并附带同一个 tool_call;continue 仅用于读工具。这次写操作没有执行,请直接重发 confirm。",
|
||||
"所有写操作都必须输出 action=confirm,并附带同一个 tool_call;continue 仅用于读工具。这次写操作没有执行,请直接重发 confirm。",
|
||||
)
|
||||
return nil
|
||||
}
|
||||
@@ -1699,28 +1717,6 @@ func executeToolCall(
|
||||
}
|
||||
|
||||
// 2. 执行工具。
|
||||
// 顺序护栏:未授权打乱顺序时,拒绝执行 min_context_switch,并写回工具观察结果。
|
||||
if shouldBlockMinContextSwitch(flowState, toolName) {
|
||||
blockedResult := "已拒绝执行 min_context_switch:当前未授权打乱顺序。如需使用该工具,请先由用户明确说明“允许打乱顺序”。"
|
||||
log.Printf(
|
||||
"[WARN] execute tool blocked chat=%s round=%d tool=%s allow_reorder=%v",
|
||||
flowState.ConversationID,
|
||||
flowState.RoundUsed,
|
||||
toolName,
|
||||
flowState.AllowReorder,
|
||||
)
|
||||
_ = emitter.EmitToolCallResult(
|
||||
executeStatusBlockID,
|
||||
executeStageName,
|
||||
toolName,
|
||||
"blocked",
|
||||
blockedResult,
|
||||
buildToolArgumentsPreviewCN(toolCall.Arguments),
|
||||
false,
|
||||
)
|
||||
appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, blockedResult)
|
||||
return nil
|
||||
}
|
||||
if shouldForceFeasibilityNegotiation(flowState, registry, toolName) {
|
||||
blockedResult := buildInfeasibleBlockedResult(flowState)
|
||||
_ = emitter.EmitToolCallResult(
|
||||
@@ -1845,19 +1841,6 @@ func buildTemporarilyDisabledToolResult(toolName string) string {
|
||||
return fmt.Sprintf("工具 %q 当前暂时禁用。请改用 move/swap/batch_move/unplace 等基础微调工具。", strings.TrimSpace(toolName))
|
||||
}
|
||||
|
||||
// shouldBlockMinContextSwitch 判断是否要拦截 min_context_switch 工具。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 仅当工具名为 min_context_switch 且未授权打乱顺序时返回 true;
|
||||
// 2. 其余场景统一放行;
|
||||
// 3. nil flowState 视为未命中拦截条件,避免因状态缺失导致误阻断。
|
||||
func shouldBlockMinContextSwitch(flowState *newagentmodel.CommonState, toolName string) bool {
|
||||
if flowState == nil {
|
||||
return false
|
||||
}
|
||||
return !flowState.AllowReorder && strings.EqualFold(strings.TrimSpace(toolName), toolMinContextSwitch)
|
||||
}
|
||||
|
||||
// executePendingTool 执行用户已确认的写工具。
|
||||
//
|
||||
// 职责边界:
|
||||
@@ -1920,22 +1903,6 @@ func executePendingTool(
|
||||
return nil
|
||||
}
|
||||
|
||||
// 3.1 顺序护栏在确认执行路径同样生效,避免绕过前置约束。
|
||||
if shouldBlockMinContextSwitch(flowState, pending.ToolName) {
|
||||
blockedResult := "已拒绝执行 min_context_switch:当前未授权打乱顺序。如需使用该工具,请先由用户明确说明“允许打乱顺序”。"
|
||||
_ = emitter.EmitToolCallResult(
|
||||
executeStatusBlockID,
|
||||
executeStageName,
|
||||
pending.ToolName,
|
||||
"blocked",
|
||||
blockedResult,
|
||||
buildToolArgumentsPreviewCN(args),
|
||||
false,
|
||||
)
|
||||
appendToolCallResultHistory(conversationContext, pending.ToolName, args, blockedResult)
|
||||
runtimeState.PendingConfirmTool = nil
|
||||
return nil
|
||||
}
|
||||
if shouldForceFeasibilityNegotiation(flowState, registry, pending.ToolName) {
|
||||
blockedResult := buildInfeasibleBlockedResult(flowState)
|
||||
_ = emitter.EmitToolCallResult(
|
||||
@@ -2060,6 +2027,24 @@ func normalizeSpeak(speak string) string {
|
||||
return speak + "\n"
|
||||
}
|
||||
|
||||
// buildExecuteNormalizedSpeakTail 计算“归一化后新增、但前端尚未收到”的 execute 文案尾巴。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只处理“streamed 原文是 normalized 的前缀”这一保守场景,典型就是只缺末尾换行;
|
||||
// 2. 不尝试回放中间格式差异,避免把整段已流式输出的正文再推一遍;
|
||||
// 3. 若无法安全判断差额,则返回空串,交给现有行为继续执行。
|
||||
func buildExecuteNormalizedSpeakTail(streamed, normalized string) string {
|
||||
streamed = strings.ReplaceAll(streamed, "\r\n", "\n")
|
||||
normalized = strings.ReplaceAll(normalized, "\r\n", "\n")
|
||||
if streamed == "" || normalized == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(normalized, streamed) {
|
||||
return ""
|
||||
}
|
||||
return normalized[len(streamed):]
|
||||
}
|
||||
|
||||
// truncateText 截断文本到指定长度。
|
||||
//
|
||||
// 用于状态推送时避免超长文本影响前端展示。
|
||||
@@ -2397,15 +2382,12 @@ func resolveToolDisplayNameCN(toolName string) string {
|
||||
"get_task_info": "查看任务详情",
|
||||
"analyze_health": "综合体检",
|
||||
"analyze_rhythm": "分析学习节奏",
|
||||
"analyze_tolerance": "分析容错空间",
|
||||
"web_search": "网页搜索",
|
||||
"web_fetch": "网页抓取",
|
||||
"move": "移动任务",
|
||||
"place": "放置任务",
|
||||
"swap": "交换任务",
|
||||
"batch_move": "批量移动任务",
|
||||
"spread_even": "均匀分散任务",
|
||||
"min_context_switch": "减少上下文切换",
|
||||
"unplace": "移除任务安排",
|
||||
"upsert_task_class": "写入任务类",
|
||||
"context_tools_add": "激活工具域",
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
newagentrouter "github.com/LoveLosita/smartflow/backend/newAgent/router"
|
||||
newagentshared "github.com/LoveLosita/smartflow/backend/newAgent/shared"
|
||||
newagentstream "github.com/LoveLosita/smartflow/backend/newAgent/stream"
|
||||
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
@@ -272,7 +271,7 @@ func handleQuickTaskQuery(
|
||||
decision *quickTaskDecision,
|
||||
flowState *newagentmodel.CommonState,
|
||||
) string {
|
||||
params := newagenttools.TaskQueryParams{
|
||||
params := newagentmodel.TaskQueryParams{
|
||||
SortBy: "deadline",
|
||||
Order: "asc",
|
||||
Limit: 5,
|
||||
@@ -316,7 +315,7 @@ func handleQuickTaskQuery(
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// quickNoteFallbackPriority 根据截止时间推断默认优先级,与 tools/quicknote.go 保持一致。
|
||||
// quickNoteFallbackPriority 根据截止时间推断默认优先级。
|
||||
func quickNoteFallbackPriority(deadline *time.Time) int {
|
||||
if deadline != nil {
|
||||
if time.Until(*deadline) <= 48*time.Hour {
|
||||
|
||||
@@ -90,7 +90,7 @@ func buildExecutePromptWithFormatGuard(base string) string {
|
||||
输出协议硬约束:
|
||||
1. 只输出当前 action 真正需要的字段;不要输出空字符串、空对象、空数组或 null 占位。
|
||||
2. tool_call 只能是 {"name":"工具名","arguments":{...}};不能写 parameters,也不能一次输出多个 tool_call。
|
||||
3. action=ask_user / confirm 时,标签后必须有自然语言正文;action=continue 可为空。
|
||||
3. action=ask_user / confirm 时,标签后必须有自然语言正文;action=continue 可为空,但只允许配合读工具或纯思考,不能携带任何写工具。
|
||||
4. action=done 时不要携带 tool_call;action=next_plan / done 时,goal_check 必须是字符串。
|
||||
5. 只有 action=abort 时才允许输出 abort 字段。
|
||||
6. <SMARTFLOW_DECISION> 标签内只放 JSON,不要放自然语言。
|
||||
@@ -111,14 +111,27 @@ func buildExecuteStrictJSONUserPrompt() string {
|
||||
执行提醒:
|
||||
- JSON 中不要包含 speak 字段;给用户看的话放在 </SMARTFLOW_DECISION> 标签之后
|
||||
- 不要在 <SMARTFLOW_DECISION> 标签之前输出任何文字;哪怕只有一句“我先看下”也不行
|
||||
- 日程写工具(place/move/swap/batch_move/unplace)一律走 action=confirm
|
||||
- 任何写工具都一律走 action=confirm,包括 upsert_task_class 与日程写工具(place/move/swap/batch_move/unplace);哪怕只是“按 validation.issues 重试一次”,也不能输出 continue + 写工具
|
||||
- 若当前处于粗排后主动优化专用模式,先调 analyze_health,再直接从 decision.candidates 里选一个合法候选去执行;不要自行发明新的全窗搜索步骤
|
||||
- 若读工具结果与已知事实明显冲突,先修正参数并重查一次,再决定是否 ask_user
|
||||
- 不要连续两轮调用“同一读工具 + 等价 arguments”;上一轮已成功返回时,下一轮必须换工具、进入 confirm,或明确说明阻塞
|
||||
- 若上下文已明确“当前未收到微调偏好,本轮先收口”,请直接输出 action=done
|
||||
- web_search 仅用于通用学习资料补充,不可用于考试时间、DDL、个人时段等时间字段填充
|
||||
- 任何写工具在真正输出 action=confirm 前,都必须先做一次“写前检查”:确认参数已齐全、格式合法、业务前提已满足;若尚未通过检查,就先补齐/归一/生成/ask_user,不要把 validation 失败当成正常探索路径
|
||||
- upsert_task_class 若返回 validation.ok=false,必须先按 validation.issues 补齐,再重试;禁止直接 done
|
||||
- 对 upsert_task_class,写前至少检查:mode=auto 时日期边界是否已满足;subject_type / difficulty_level / cognitive_intensity 是否齐;difficulty_level 是否已映射到合法枚举;items 是否非空且顺序内容已生成;config 中已知约束字段是否已落到合法格式
|
||||
- 若像 items 这种内容本就由当前轮模型负责生成,就应先把内容生成齐、顺序排好,再写入;不要先写一个 items 为空的 taskclass 去让 validation 提醒你补内容
|
||||
- 处理 validation.issues 时先分类:若是用户关键信息确实缺失,才 action=ask_user;若是 schema 字段名、字段位置、内部索引、枚举值、日期格式、工具语义映射等内部表示问题,应静默改参后直接重试,不要把底层表示教学抛给用户
|
||||
- 像 config.excluded_slots 的半天块索引映射,默认属于内部表示修正:你应自己把“第1-2节 / 第11-12节”换算成合法块索引,不要为此 ask_user,不要长篇解释底层表示
|
||||
- 当前时间锚点只用于解析用户已经明确说出的相对时间(如“今天开始”“两周内”“下周一前”),不能反过来把“现在是今天”当成用户已经同意从今天开始,更不能据此默认生成 start_date / end_date
|
||||
- 像 auto 模式缺 start_date/end_date 这类问题,先检查当前对话、历史、记忆、已知工具结果里是否已经出现可用日期;若已出现就静默补齐并重试,只有在上下文里确实没有时再 ask_user
|
||||
- 若当前是首次创建/修正 taskclass,且上下文里并没有用户明确给出的开始日期、结束日期、日期范围、完成期限、或可直接换算出的相对时间承诺,就不要擅自写 start_date / end_date;此时若工具闭环确实要求这些字段,必须 ask_user
|
||||
- 对 taskclass 来说,以下属于必须 ask_user 的关键信息:start_date、end_date、明确的日期范围、明确的开始时间承诺、明确的完成期限;这些会决定任务类的真实时间边界,不能由模型自行拍板
|
||||
- subject_type / difficulty_level / cognitive_intensity 是任务类语义画像必填;优先静默推断,只有确实无法判断时再 ask_user
|
||||
- 学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,这些默认都只是 taskclass 语义;不要因为信息完整就自动切进 schedule
|
||||
- 只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才允许 context_tools_add domain="schedule"、触发 rough_build,或继续 schedule 链路
|
||||
- 例:“我要复习离散数学,基础较差,大概学 8 节课,不要早上第 1-2 节和晚上第 11-12 节,周末也不想学,每节课内容你自己来”——应先生成或更新 taskclass,而不是主动排进日程
|
||||
- 仅 upsert_task_class 成功不代表已开始排程;若未触发 rough_build 且未调用任何日程修改工具,禁止承诺“接下来会自动排程”
|
||||
- 当前轮目标若是创建/修正 taskclass,就优先把 taskclass 静默闭环;除非真缺用户关键信息,否则不要把主要篇幅花在解释工具内部约束上
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -231,9 +231,9 @@ func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.C
|
||||
|
||||
if state != nil {
|
||||
if state.AllowReorder {
|
||||
lines = append(lines, "- 顺序策略:用户已明确允许打乱顺序,可在必要时使用 min_context_switch。")
|
||||
lines = append(lines, "- 顺序策略:用户已明确允许打乱顺序,但当前主链不再提供顺序重排工具,请优先使用 move/swap 做局部调整。")
|
||||
} else {
|
||||
lines = append(lines, "- 顺序策略:默认保持 suggested 相对顺序,禁止调用 min_context_switch。")
|
||||
lines = append(lines, "- 顺序策略:默认保持 suggested 相对顺序,仅做局部 move/swap 调整。")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.C
|
||||
//
|
||||
// 1. 这里只给模型最低必要的参数和返回值感知,不重复塞完整 schema JSON。
|
||||
// 2. 对复杂工具额外给一条调用示例,降低“参数字段写错”的概率。
|
||||
// 3. P1 阶段隐藏 min_context_switch,避免模型误用已禁能力。
|
||||
// 3. 这里只展示当前真实可用工具,避免历史残留能力继续污染工具面。
|
||||
func renderExecuteToolCatalogCompact(ctx *newagentmodel.ConversationContext, state *newagentmodel.CommonState) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
@@ -286,10 +286,6 @@ func renderExecuteToolCatalogCompact(ctx *newagentmodel.ConversationContext, sta
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if shouldHideMinContextSwitchForP1(state, name) {
|
||||
continue
|
||||
}
|
||||
|
||||
index++
|
||||
desc := strings.TrimSpace(schemaItem.Desc)
|
||||
if desc == "" {
|
||||
@@ -329,7 +325,6 @@ func shouldRenderExecuteToolReturnSample(toolName string) bool {
|
||||
"web_fetch",
|
||||
"analyze_health",
|
||||
"analyze_rhythm",
|
||||
"analyze_tolerance",
|
||||
"upsert_task_class":
|
||||
return true
|
||||
default:
|
||||
@@ -340,7 +335,7 @@ func shouldRenderExecuteToolReturnSample(toolName string) bool {
|
||||
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":"逆矩阵与矩阵的秩"}]}}}`
|
||||
return `仅当用户或上下文已明确给出日期范围时,才允许写入 start_date/end_date;写前先检查 difficulty_level 已归一为 low/medium/high,items 已非空且内容顺序已生成完成:{"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,6],"excluded_days_of_week":[6,7]},"items":[{"order":1,"content":"行列式定义与基础计算"},{"order":2,"content":"矩阵及其运算规则"},{"order":3,"content":"逆矩阵与矩阵的秩"}]}}}`
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -375,10 +370,6 @@ func renderExecuteToolReturnHint(toolName string) (returnType string, sample str
|
||||
return returnType, "交换完成:[35]... ↔ [36]..."
|
||||
case "batch_move":
|
||||
return returnType, "批量移动完成,2 个任务全部成功。"
|
||||
case "spread_even":
|
||||
return returnType, "均匀化调整完成:共处理 6 个任务,候选坑位 24 个。"
|
||||
case "min_context_switch":
|
||||
return returnType, "最少上下文切换重排完成:共处理 6 个任务,上下文切换次数 5 -> 2。"
|
||||
case "unplace":
|
||||
return returnType, "已将 [35]... 移除,恢复为待安排状态。"
|
||||
case "web_search":
|
||||
@@ -389,8 +380,6 @@ func renderExecuteToolReturnHint(toolName string) (returnType string, sample str
|
||||
return "string(JSON字符串)", `{"tool":"analyze_health","success":true,"metrics":{"rhythm":{"avg_switches_per_day":1.1,"max_switch_count":4,"heavy_adjacent_days":2,"same_type_transition_ratio":0.58,"block_balance":0,"fragmented_count":0,"compressed_run_count":0},"tightness":{"locally_movable_task_count":3,"avg_local_alternative_slots":1.7,"cross_class_swap_options":1,"forced_heavy_adjacent_days":0,"tightness_level":"tight"},"can_close":false},"decision":{"should_continue_optimize":true,"recommended_operation":"swap","primary_problem":"第4天存在高认知背靠背","candidates":[{"candidate_id":"swap_35_44","tool":"swap","arguments":{"task_a":35,"task_b":44}}]}}`
|
||||
case "analyze_rhythm":
|
||||
return "string(JSON字符串)", `{"tool":"analyze_rhythm","success":true,"metrics":{"overview":{"avg_switches_per_day":3.4,"max_switch_day":4,"max_switch_count":5,"heavy_adjacent_days":2,"long_high_intensity_days":1,"same_type_transition_ratio":0.42}}}`
|
||||
case "analyze_tolerance":
|
||||
return "string(JSON字符串)", `{"tool":"analyze_tolerance","success":true,"metrics":{"overall":{"fragmentation_rate":0.52,"days_without_buffer":1}}}`
|
||||
case "upsert_task_class":
|
||||
return "string(JSON字符串)", `{"tool":"upsert_task_class","success":true,"task_class_id":123,"created":true,"validation":{"ok":true,"issues":[]},"error":"","error_code":""}`
|
||||
default:
|
||||
@@ -564,9 +553,8 @@ func hasExecuteRoughBuildDone(ctx *newagentmodel.ConversationContext) bool {
|
||||
|
||||
func renderExecuteLatestAnalyzeSummary(ctx *newagentmodel.ConversationContext) string {
|
||||
record, ok := findExecuteLatestToolRecord(ctx, map[string]struct{}{
|
||||
"analyze_health": {},
|
||||
"analyze_rhythm": {},
|
||||
"analyze_tolerance": {},
|
||||
"analyze_health": {},
|
||||
"analyze_rhythm": {},
|
||||
})
|
||||
if !ok {
|
||||
return ""
|
||||
@@ -582,8 +570,6 @@ func renderExecuteLatestMutationSummary(ctx *newagentmodel.ConversationContext)
|
||||
"batch_move": {},
|
||||
"unplace": {},
|
||||
"queue_apply_head_move": {},
|
||||
"spread_even": {},
|
||||
"min_context_switch": {},
|
||||
})
|
||||
if !ok {
|
||||
return ""
|
||||
@@ -790,14 +776,13 @@ func renderTaskClassUpsertRuntime(state *newagentmodel.CommonState) string {
|
||||
}
|
||||
}
|
||||
if !state.TaskClassUpsertLastSuccess {
|
||||
lines = append(lines, "- 写前最少检查项:mode=auto 的 start_date/end_date、subject_type/difficulty_level/cognitive_intensity、difficulty_level 合法枚举、items 非空且内容已生成、config 约束字段合法。")
|
||||
lines = append(lines, "- 先判断当前 issues 属于哪一类:若是 schema 字段名、字段位置、半天块索引、枚举值、日期格式、工具语义映射等内部表示问题,直接静默改参重试。")
|
||||
lines = append(lines, "- 若 issue 指向 start_date/end_date 等字段,先检查当前对话、历史、记忆、最近工具结果里是否已出现可用值;只有确实没有时再 ask_user。")
|
||||
lines = append(lines, "- 若缺的是 start_date/end_date/日期范围/开始日期承诺/完成期限,而这些值并未在上下文中出现,就必须 ask_user;不能把当前日期或默认周期当成用户已同意的时间边界。")
|
||||
lines = append(lines, "- 若 issue 像 difficulty_level 非法、items 为空、约束字段格式不合法,就先在本轮静默归一/补齐/生成,再 confirm 重试;不要把 validation 当试错器。")
|
||||
lines = append(lines, "- 若再次调用 upsert_task_class,动作必须是 confirm,不能输出 continue + tool_call。")
|
||||
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
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ func renderExecuteNextStepHintV2(
|
||||
if roughBuildDone {
|
||||
return `先激活 schedule 业务域;当前是粗排后的微调场景,通常至少需要 mutation+analyze。若要按统一条件逐个处理一批任务,再加 packs=["queue"]。`
|
||||
}
|
||||
return `先判断当前任务属于哪个业务域,再用 context_tools_add 激活对应工具。`
|
||||
return `先判断当前任务属于哪个业务域,再用 context_tools_add 激活对应工具。若用户只是在描述学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,默认先走 taskclass;只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才切 schedule。`
|
||||
}
|
||||
|
||||
if activeDomain == "schedule" &&
|
||||
@@ -97,7 +97,7 @@ func renderExecuteNextStepHintV2(
|
||||
if activeDomain == "taskclass" &&
|
||||
state.TaskClassUpsertLastTried &&
|
||||
!state.TaskClassUpsertLastSuccess {
|
||||
return `先根据 validation.issues 补齐缺失字段,再重试 upsert_task_class,不要直接收口。`
|
||||
return `先判断 validation.issues 是“用户缺信息”还是“内部表示修正”;能从上下文补的先静默补齐,再用 confirm 重试 upsert_task_class,不要继续解释底层约束,更不要直接收口。`
|
||||
}
|
||||
|
||||
return ""
|
||||
|
||||
@@ -178,9 +178,11 @@ func buildExecuteCoreMinPack() executeRulePack {
|
||||
Name: executeRulePackCoreMin,
|
||||
Content: strings.TrimSpace(fmt.Sprintf(`
|
||||
- 当前时间锚点:%s。涉及“今天/明天/本周”等相对时间时,先按该锚点换算。
|
||||
- 用户意图优先:只推进用户当前明确要求;未明确部分优先 ask_user。
|
||||
- 用户意图优先:只推进用户当前明确要求;未明确部分先看能否从当前对话、历史、记忆、已知工具结果里静默补齐,只有补不出来时再 ask_user。
|
||||
- 域切换要克制:用户若只是在描述学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,这默认仍是 taskclass,不要主动切到 schedule。
|
||||
- 只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才允许进入 schedule 或触发粗排。
|
||||
- 先事实后动作:优先读工具补齐事实,再决定下一步。
|
||||
- 只要决定调用 place/move/swap/batch_move/unplace 这类写工具,就必须输出 action=confirm;continue + 写工具无效。
|
||||
- 只要决定调用任何写工具,就必须输出 action=confirm;continue + 写工具无效。这个纪律同样适用于 upsert_task_class 的每一次重试。
|
||||
- 输出格式固定:先 <SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>,再输出用户可见正文。`,
|
||||
buildExecuteNowAnchorLine())),
|
||||
}
|
||||
@@ -197,9 +199,9 @@ func buildExecuteSafetyHardPack() executeRulePack {
|
||||
Name: executeRulePackSafetyHard,
|
||||
Content: strings.TrimSpace(`
|
||||
- 严禁伪造工具结果;若新结果与既有事实冲突,先重查一次再决定。
|
||||
- P1 阶段禁止调用 min_context_switch。
|
||||
- 工具参数必须严格使用 schema 字段名,禁止自造别名。
|
||||
- JSON 只保留当前 action 必需字段;不要输出空字符串、空对象、空数组或 null 占位。
|
||||
- P1 阶段禁止调用 min_context_switch。
|
||||
- 连续两轮同类读查询后,必须转执行 / ask_user / 明确说明阻塞,不能无限空转。`),
|
||||
}
|
||||
}
|
||||
@@ -210,6 +212,7 @@ func buildExecuteContextProtocolPack() executeRulePack {
|
||||
Content: strings.TrimSpace(`
|
||||
- msg0 动态区初始仅保留 context_tools_add / context_tools_remove。
|
||||
- 需要业务工具前先 context_tools_add:排程用 domain="schedule",任务类写入用 domain="taskclass"。
|
||||
- 切 schedule 前先判断用户是否明确提出排程诉求;若只是描述任务类内容与排程偏好,先留在 taskclass。
|
||||
- schedule 可选 packs=["mutation","analyze","detail_read","deep_analyze","queue","web"];core 固定注入,不要显式传 core。
|
||||
- 只在业务方向切换时再 remove;done 后的动态区清理由系统自动完成,不必手动 remove。
|
||||
- 如果目标工具当前不在可用列表,先 add 对应 domain / packs,再继续执行。`),
|
||||
@@ -232,7 +235,7 @@ func buildExecuteModeReActPack() executeRulePack {
|
||||
Name: executeRulePackModeReAct,
|
||||
Content: strings.TrimSpace(`
|
||||
- 当前为自由执行(ReAct)模式:可自主决定 continue / confirm / ask_user / done / abort。
|
||||
- 如果关键事实无法通过工具补齐,优先 ask_user,不做猜测落库。
|
||||
- 如果关键事实既无法通过工具补齐,也无法从当前对话、历史、记忆中补齐,才 ask_user;不要把本可静默修正的内部表示问题转嫁给用户。
|
||||
- 自主推进时要小步快跑,优先闭合当前局部问题,不要发散成大范围开放搜索。`),
|
||||
}
|
||||
}
|
||||
@@ -242,6 +245,8 @@ func buildExecuteSchedulePack() executeRulePack {
|
||||
Name: executeRulePackDomainSchedule,
|
||||
Content: strings.TrimSpace(`
|
||||
- 当前业务域为 schedule:只处理当前目标任务类,不重排无关内容。
|
||||
- 只有用户已明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才应停留或切入 schedule。
|
||||
- 单纯看到总节数、难度、节次偏好、禁排时段、排除星期,不足以进入 schedule;这些默认仍属于 taskclass 约束。
|
||||
- existing 只作事实参考;真正可调对象优先看 suggested。
|
||||
- 同任务类内部顺序必须保持,任何越过前驱/后继边界的移动都会被写工具拒绝。`),
|
||||
}
|
||||
@@ -281,8 +286,28 @@ func buildExecuteTaskClassPack() executeRulePack {
|
||||
Name: executeRulePackDomainTaskClass,
|
||||
Content: strings.TrimSpace(`
|
||||
- taskclass 域只负责生成或修正任务类,不代表已经开始排程。
|
||||
- 学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,默认都先落在 taskclass 语义中。
|
||||
- 例:“我要复习离散数学,基础较差,大概学 8 节课,不要早上第 1-2 节和晚上第 11-12 节,周末也不想学,每节课内容你自己来”——应进入或停留 taskclass,而不是主动切 schedule,也通常不需要 ask_user。
|
||||
- 在真正调用 upsert_task_class 前,必须先做一轮写前检查;只有当参数已齐全、格式合法、业务前提已满足时,才允许输出 confirm。
|
||||
- 不要把 validation 失败当成正常试错器;validation 只用于兜底发现漏项,不应成为“先乱写一次看看后端报什么”的主流程。
|
||||
- upsert_task_class 写前最少检查项:
|
||||
1. mode=auto 时,task_class 顶层 start_date/end_date 是否已经满足。
|
||||
2. subject_type / difficulty_level / cognitive_intensity 是否齐全。
|
||||
3. difficulty_level 是否已归一到合法枚举 low/medium/high。
|
||||
4. items 是否非空,且顺序与内容是否已在当前轮生成完成。
|
||||
5. config 中已知约束字段是否已是合法格式,例如 excluded_slots 半天块索引、excluded_days_of_week 取值范围、total_slots/strategy 等。
|
||||
- 若像 items 这种内容本就由当前轮模型负责生成,就应先生成齐再写,不要把空 items 提交给 validation 去提醒你补课表内容。
|
||||
- upsert_task_class 若返回 validation.ok=false,必须先处理 validation.issues,再考虑重试或 ask_user。
|
||||
- 先区分 issue 类型:schema 字段名、字段位置、内部索引、枚举值、日期格式、工具语义映射,属于内部表示修正,应静默改参后直接重试;真正缺少用户关键信息时,才 ask_user。
|
||||
- taskclass 里的“关键信息缺失”要收窄定义:真正必须 ask_user 的,是会决定任务类真实时间边界/时间承诺的字段,而不是内部表示问题。
|
||||
- 必须 ask_user 的时间参数/条件包括:start_date、end_date、明确日期范围、明确开始日期承诺、明确完成期限;如果这些信息在当前对话、历史、记忆里都不存在,就不能由你自行拍板。
|
||||
- 当前时间锚点只能用来解析用户已经说出的相对时间;若用户没说“今天开始 / 本周内 / 两周内 / 下周前”这类时间承诺,不能因为“今天是 2026-04-27”就默认 start_date=今天,也不能默认补一个 end_date。
|
||||
- 禁排时段、排除星期、总节数、难度、内容拆分授权,不等于用户已经给出了日期范围;这些信息再完整,也不能单独推出 start_date/end_date。
|
||||
- config.excluded_slots 使用 1~6 的半天块索引;像“第1-2节”应映射到 1,“第11-12节”应映射到 6。这类换算由你内部处理,不要把底层表示解释成主要回复内容。
|
||||
- 若 validation 指出 auto 模式缺 start_date/end_date,先检查当前对话、历史、记忆里是否已有日期范围;已有就静默补齐并重试,只有确实没有时再 ask_user。
|
||||
- subject_type / difficulty_level / cognitive_intensity 是任务类语义画像必填项;优先静默推断,只有确实无法判断时再 ask_user。
|
||||
- 只要再次调用 upsert_task_class,无论是首次写入还是失败后的重试,都必须走 action=confirm。
|
||||
- 当前轮目标若是创建/修正 taskclass,应优先追求静默闭环,不要把主要篇幅花在教育用户理解工具内部约束上。
|
||||
- excluded_slots 取值应与系统节次定义一致;excluded_days_of_week 使用 1~7 表示周一到周日。`),
|
||||
}
|
||||
}
|
||||
@@ -301,6 +326,13 @@ func buildExecuteTaskClassRetryMicroPack() executeRulePack {
|
||||
Name: executeRulePackMicroTaskRetry,
|
||||
Content: strings.TrimSpace(`
|
||||
- 最近一次 upsert_task_class 失败时,优先围绕 validation.issues 修补。
|
||||
- 先回到“写前检查”再决定是否重试:确认 mode=auto 的日期边界、difficulty_level 合法枚举、subject_type/difficulty_level/cognitive_intensity 齐全、items 非空且已生成、config 约束字段合法。
|
||||
- 先判断 issue 是“用户关键信息缺失”还是“内部表示/工具语义修正”:前者才 ask_user,后者直接静默改参重试。
|
||||
- 如果 issue 最终落到 start_date / end_date / 日期范围 / 开始日期承诺 / 完成期限,而这些值在当前对话、历史、记忆、最近工具结果里都没有出现,就必须 ask_user;不要再拿当前时间锚点去替用户补。
|
||||
- 若用户只给了禁排时段、排除星期、总节数、难度、内容拆分授权,这仍不构成日期范围;不要把这类偏好误判成已经拿到了可写入的 start_date/end_date。
|
||||
- 如果 issue 像 difficulty_level 非法、items 为空、约束字段格式不合法,这都属于“写前本应整理好”的问题:应先在本轮静默归一/补齐/生成,再 confirm 重试,不要继续拿 validation 探路。
|
||||
- 若 issue 所需字段已在当前对话、历史、记忆或最近工具结果里出现,优先静默补齐,不要多轮解释后再写。
|
||||
- 重试 upsert_task_class 时仍然必须输出 action=confirm;不要输出 continue + tool_call。
|
||||
- 问题未解决前,不要用 done 假装收口;要么重试,要么 ask_user 补关键信息。`),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,15 +13,24 @@ const planSystemPromptCore = `
|
||||
|
||||
最高优先级规则:
|
||||
1. 意图边界:只规划用户当前明确要求,禁止擅自扩展后续动作。
|
||||
2. 事实边界:禁止伪造工具调用和执行结果。
|
||||
2. 事实边界:禁止伪造工具调用、工具结果、外部事实和执行结论。
|
||||
3. 规划视角:先判断“最小工具闭环”再写步骤;不要先写抽象语义步骤,再让 execute 自己猜该怎么落工具。
|
||||
|
||||
规划规则:
|
||||
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。`
|
||||
4. plan_steps 必须优先按“工具闭环”拆步,而不是按抽象语义拆步。
|
||||
5. 若一个目标可由单个工具闭环完成,优先生成单步计划;禁止把本可直接执行的工具动作,拆成“先分析、再设计、再确认、再执行”这类抽象多步。
|
||||
6. 每个 step 的 done_when 都应尽量贴近可观察证据,优先锚定工具回执、校验结果、查询 observation,而不是“方案完整”“分析完成”“用户应该满意”这类抽象描述。
|
||||
7. 只有单工具无法闭环,或当前步骤天然依赖上一步 observation / 用户补充信息时,才允许拆成多步。
|
||||
8. 先判断为完成目标“首个可执行闭环”最小需要的 domain / packs,再围绕这些工具写 steps,最后再产出 context_hook。context_hook 不是顺手填空,而是计划的自然推导结果。
|
||||
9. context_hook 只有一份,供 execute 首轮激活工具域使用;它应对齐“第一个可执行 step”的最小工具需求,而不是试图一次覆盖整份计划的所有后续能力。
|
||||
10. 用户若只是在描述学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,这默认仍是 taskclass 语义,不等于已经要求排进日程。
|
||||
11. 只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才允许把目标规划为 schedule;否则优先停留在 taskclass。
|
||||
12. 若意图满足批量排程识别条件,可在 plan_done 时附加 needs_rough_build 与 task_class_ids;但仅当用户明确提出排程请求时才允许这样做。
|
||||
13. 可在 plan_done 时附加 context_hook(执行阶段注入建议);若用户尚未明确要求排程,则 context_hook.domain 不得写 schedule。规划阶段禁止调用 context_tools_add/remove。
|
||||
14. 例:“我要复习离散数学,基础较差,大概学 8 节课,不要早上第 1-2 节和晚上第 11-12 节,周末也不想学,每节课内容你自己来”——这应判定为 taskclass 设计;planner 应优先理解为 taskclass 域可闭环的请求,通常单步或极少步即可,不应抽象拆成多轮。`
|
||||
|
||||
// BuildPlanSystemPrompt 返回规划阶段系统提示词。
|
||||
func BuildPlanSystemPrompt() string {
|
||||
@@ -33,10 +42,6 @@ func BuildPlanSystemPrompt() string {
|
||||
}
|
||||
|
||||
// BuildPlanMessages 组装规划阶段的 messages。
|
||||
//
|
||||
// 1. 规划阶段只保留 Planner 专用规则,跳过通用人格底座,避免角色指令冲突。
|
||||
// 2. msg1 展示真实对话,msg2 展示规划工作区,msg3 仅给最小执行指令与用户本轮输入。
|
||||
// 3. 工具目录使用轻量版,仅提供“有什么工具”,不注入执行态大段参数示例。
|
||||
func BuildPlanMessages(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext, userInput string) []*schema.Message {
|
||||
return buildUnifiedStageMessages(
|
||||
ctx,
|
||||
@@ -58,6 +63,9 @@ func BuildPlanUserPrompt(state *newagentmodel.CommonState, userInput string) str
|
||||
|
||||
sb.WriteString("请继续当前任务规划,只输出一组 SMARTFLOW_DECISION 决策。\n")
|
||||
sb.WriteString("请基于最近对话与规划工作区推进,不要重复已有计划内容。\n")
|
||||
sb.WriteString("请先判断最小工具闭环,再决定是否需要拆步;能单步就单步。\n")
|
||||
sb.WriteString("若需要 context_hook,请先根据第一个可执行 step 所需的最小 domain / packs 推导,再写入 hook。\n")
|
||||
sb.WriteString("禁止把本可直接落工具的动作,抽象写成“完成设计 / 确认方案 / 整理思路”之类空步骤。\n")
|
||||
sb.WriteString("输出格式与字段约束严格按 msg0 协议执行。\n")
|
||||
|
||||
trimmedInput := strings.TrimSpace(userInput)
|
||||
@@ -72,28 +80,38 @@ 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 字段:
|
||||
- action:只能是 %s / %s / %s
|
||||
- reason:给后端和日志看的简短说明
|
||||
- complexity:只能是 simple / moderate / complex
|
||||
- plan_steps:仅当 action=%s 时允许返回,且必须是完整计划
|
||||
- plan_steps[].content:步骤正文,必填
|
||||
- plan_steps[].done_when:可选,建议写完成判定
|
||||
- needs_rough_build:仅满足粗排识别条件时为 true,否则省略
|
||||
- task_class_ids:needs_rough_build=true 时必填,从上下文读取
|
||||
- context_hook:可选,仅用于给 execute 阶段提供注入建议
|
||||
- context_hook.domain:schedule / taskclass
|
||||
- context_hook.packs:string 数组,可选;core 固定注入,不要填写 core
|
||||
- context_hook.reason:可选,说明为何建议该注入
|
||||
|
||||
注意:
|
||||
- JSON 中不要包含 speak 字段
|
||||
- 不要在 planning 阶段调用任何工具(包括 context_tools_add/remove)`,
|
||||
return strings.TrimSpace(fmt.Sprintf(strings.Join([]string{
|
||||
"输出协议(唯一口径):",
|
||||
"1. 先输出:<SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>",
|
||||
"2. 再输出:给用户看的自然语言正文",
|
||||
"",
|
||||
"JSON 字段:",
|
||||
"- action:只能是 %s / %s / %s",
|
||||
"- reason:给后端和日志看的简短说明",
|
||||
"- complexity:只能是 simple / moderate / complex",
|
||||
"- plan_steps:仅当 action=%s 时允许返回,且必须是完整计划",
|
||||
"- plan_steps[].content:步骤正文,必填",
|
||||
"- plan_steps[].done_when:可选;若提供,必须尽量写成 observation / 工具回执可直接证明的完成判定",
|
||||
"- needs_rough_build:仅满足粗排识别条件时为 true,否则省略",
|
||||
"- task_class_ids:needs_rough_build=true 时必填,从上下文读取",
|
||||
"- context_hook:可选,仅用于给 execute 阶段提供注入建议",
|
||||
"- context_hook.domain:schedule / taskclass",
|
||||
"- context_hook.packs:string 数组,可选;core 固定注入,不要填入 core",
|
||||
"- context_hook.reason:可选,说明为何建议该注入",
|
||||
"",
|
||||
"注意:",
|
||||
"- JSON 中不要包含 speak 字段",
|
||||
"- 不要在 planning 阶段调用任何工具(包括 context_tools_add/remove)",
|
||||
"- 写 plan_steps 前,先判断当前目标能否由单个工具或单个紧凑工具闭环完成;若能,优先输出单步计划",
|
||||
"- 禁止把本可直接执行的工具动作,拆成抽象语义步骤,例如“先分析需求”“完成设计”“确认方案完整”",
|
||||
"- 多步计划只应用于:上一步 observation 决定下一步;或确实需要先问用户补关键事实;或目标天然跨域",
|
||||
"- context_hook 必须从 plan_steps 自然推导:优先对齐第一个可执行 step 的最小 domain / packs,不要脱离步骤单独拍脑袋生成",
|
||||
"- 若用户只给出学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,这默认属于 taskclass 设计;不要因此写 needs_rough_build=true,也不要把 context_hook.domain 设为 schedule",
|
||||
"- 只有用户明确要求\"排进日程 / 给出具体时间安排 / 现在就排一版\"时,才允许输出 needs_rough_build=true 或 context_hook.domain=schedule",
|
||||
"- 若首步本质上是任务类写入或修正,context_hook 通常应对齐 taskclass;若首步需要 schedule 查询/分析/修改,再按最小 packs 推导 schedule hook",
|
||||
"- step 的 done_when 应优先锚定:查询结果已返回、validation 已通过、写工具已成功回执、粗排标记已产生、分析结论已可直接支撑下一步",
|
||||
"- 例:\"我要复习离散数学,基础较差,大概学 8 节课,不要早上第 1-2 节和晚上第 11-12 节学习,周末也不想学,每节课内容你自己来\"——应规划为 taskclass,而不是 schedule,也通常不需要 ask_user",
|
||||
}, "\n"),
|
||||
newagentmodel.PlanActionContinue,
|
||||
newagentmodel.PlanActionAskUser,
|
||||
newagentmodel.PlanActionDone,
|
||||
|
||||
@@ -16,13 +16,14 @@ func buildPlanConversationMessage(ctx *newagentmodel.ConversationContext) string
|
||||
// buildPlanWorkspace 渲染 plan 节点自己的工作区。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 这里只保留“规划真正需要知道的东西”:已有计划、当前步骤、task_class_ids、任务类约束;
|
||||
// 2. 不再复用通用胖状态摘要,避免把 execute / deliver 无关状态一起塞给 plan;
|
||||
// 3. 若当前没有正式计划,则明确告诉模型“从零开始规划”,避免继续误沿用旧上下文。
|
||||
// 1. 这里既保留“当前已有计划/任务类约束”,也显式补充“规划视角的工具摘要”;
|
||||
// 2. planner 需要先理解工具边界,才能把步骤收敛到最小闭环,而不是按抽象语义乱拆;
|
||||
// 3. 工具摘要不展开全量 schema,只提供规划真正需要的:负责什么、不负责什么、常见闭环、完成证据、域切换条件。
|
||||
func buildPlanWorkspace(state *newagentmodel.CommonState) string {
|
||||
lines := []string{"规划工作区:"}
|
||||
if state == nil {
|
||||
lines = append(lines, "- 当前缺少流程状态,请主要依据最近对话与本轮输入继续规划。")
|
||||
lines = append(lines, buildPlanToolPlanningSummary())
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
@@ -43,6 +44,7 @@ func buildPlanWorkspace(state *newagentmodel.CommonState) string {
|
||||
lines = append(lines, taskClassMeta)
|
||||
}
|
||||
|
||||
lines = append(lines, buildPlanToolPlanningSummary())
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
@@ -142,6 +144,76 @@ func renderPlanTaskClassMeta(state *newagentmodel.CommonState) string {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// buildPlanToolPlanningSummary 生成“规划视角的工具摘要”。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先讲 domain:让 planner 先判断目标应该停留在哪个业务域;
|
||||
// 2. 再讲 schedule packs:让 planner 知道若进入 schedule,该选最小哪组能力;
|
||||
// 3. 最后讲 hook 推导规则:因为 context_hook 只有一份,必须和“首个可执行闭环”对齐。
|
||||
func buildPlanToolPlanningSummary() string {
|
||||
sections := []string{
|
||||
"规划视角的工具摘要:",
|
||||
buildPlanToolDomainTaskClassSummary(),
|
||||
buildPlanToolDomainScheduleSummary(),
|
||||
buildPlanToolPackSummary(),
|
||||
buildPlanContextHookSummary(),
|
||||
}
|
||||
return strings.Join(sections, "\n")
|
||||
}
|
||||
|
||||
func buildPlanToolDomainTaskClassSummary() string {
|
||||
lines := []string{
|
||||
"1. taskclass 域:",
|
||||
"- 负责什么:创建 / 更新任务类,沉淀学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权、任务项结构。",
|
||||
"- 不负责什么:不给出具体日期/节次落位,不负责把任务真正排进日程。",
|
||||
"- 常见一步闭环:任务类设计或修正通常可由 taskclass 域单步闭环,核心写入动作为 upsert_task_class。",
|
||||
"- 何时停留在本域:用户仍在描述目标、偏好、约束、拆分方式,而不是要求现在排进日程。",
|
||||
"- 何时切到下一个域:只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”,或当前目标本身已变成排程执行。",
|
||||
"- done_when 证据偏好:优先锚定 upsert_task_class 成功回执、validation.ok=true、validation.issues 已清空。",
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func buildPlanToolDomainScheduleSummary() string {
|
||||
lines := []string{
|
||||
"2. schedule 域:",
|
||||
"- 负责什么:查询日程现状、粗排、具体落位、局部移动/交换、批量同规则调整、排程健康分析。",
|
||||
"- 不负责什么:不凭空补考试时间、DDL、个人空闲、外部时间事实;这类信息拿不到时应 ask_user。",
|
||||
"- 常见一步闭环:单次查询通常一个读工具即可闭环;单次移动/交换/放置通常一个写工具即可闭环;局部分析通常一个 analyze 工具即可闭环。",
|
||||
"- 何时停留在本域:用户明确要求查询、安排、调整、优化当前日程。",
|
||||
"- 何时先回 taskclass:如果用户还在定义“学什么、学多少、怎么拆、哪些时段不要学”,而不是要求立刻排程,应先停留在 taskclass。",
|
||||
"- done_when 证据偏好:优先锚定查询 observation、写工具成功回执、rough_build_done 标记、analyze observation 已能直接支撑下一步。",
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func buildPlanToolPackSummary() string {
|
||||
lines := []string{
|
||||
"3. schedule packs 选择参考:",
|
||||
"- detail_read:查看总览、查询区间、看任务详情;适合“先读事实再决定”的首步。",
|
||||
"- mutation:place / move / swap / batch_move / unplace;适合真正落日程或调日程。",
|
||||
"- analyze:analyze_health / analyze_rhythm;适合先判断是否还有优化空间、该往哪里动。",
|
||||
"- queue:适合“按同一规则逐个处理一批任务”的计划,不必把整批任务细节都堆进 steps。",
|
||||
"- web:仅补通用学习资料或通识信息;不用于补个人时间事实。",
|
||||
"- deep_analyze:适合确实需要更深一层 schedule 分析时再加,默认不要为了“看起来完整”就提前注入。",
|
||||
"- 选 pack 原则:只选首个可执行 step 真的需要的最小 packs,不要为了保险一次全带上。",
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func buildPlanContextHookSummary() string {
|
||||
lines := []string{
|
||||
"4. context_hook 推导规则:",
|
||||
"- 先确定 steps,再看第一个可执行 step 需要哪个 domain / 哪组最小 packs,最后才写 hook。",
|
||||
"- 若第一个可执行 step 本质上是任务类写入或修正,hook 通常应为 taskclass,且一般不需要 packs。",
|
||||
"- 若第一个可执行 step 是 schedule 查询,hook 应为 schedule,并优先只带 detail_read。",
|
||||
"- 若第一个可执行 step 是 schedule 分析,hook 应为 schedule,并优先带 analyze;若分析后立刻要落写,再补 mutation。",
|
||||
"- 若第一个可执行 step 是批量同规则处理,hook 应在 schedule 基础上按需加 queue。",
|
||||
"- hook 只有一份,不要求提前覆盖整份计划的所有后续能力;execute 可以在后续按计划再切域或补 packs。",
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func planSemanticValue(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
package newagenttools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
newagentshared "github.com/LoveLosita/smartflow/backend/newAgent/shared"
|
||||
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
|
||||
)
|
||||
|
||||
// QuickNoteDeps 描述随口记工具所需的外部依赖。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. CreateTask 负责真正写库,工具层不直接依赖 DAO;
|
||||
// 2. UserID 由 execute 节点通过 args["_user_id"] 注入,工具层不自行解析会话身份。
|
||||
type QuickNoteDeps struct {
|
||||
// CreateTask 将解析后的任务字段写入数据库。
|
||||
// 调用目的:解耦工具层与 DAO 层,方便测试和替换。
|
||||
CreateTask func(userID int, title string, priorityGroup int, deadlineAt *time.Time) (taskID int, err error)
|
||||
}
|
||||
|
||||
// QuickNoteCreateResult 是 quick_note_create 工具的结构化返回。
|
||||
type QuickNoteCreateResult struct {
|
||||
TaskID int `json:"task_id"`
|
||||
Title string `json:"title"`
|
||||
PriorityLabel string `json:"priority_label"`
|
||||
DeadlineAt string `json:"deadline_at,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// quickNoteFallbackPriority 根据截止时间推断默认优先级。
|
||||
//
|
||||
// 推断规则:
|
||||
// 1. 有截止时间且距今 ≤48h → 1(重要且紧急);
|
||||
// 2. 有截止时间且距今 >48h → 2(重要不紧急);
|
||||
// 3. 无截止时间 → 3(简单不重要)。
|
||||
func quickNoteFallbackPriority(deadline *time.Time) int {
|
||||
if deadline != nil {
|
||||
if time.Until(*deadline) <= 48*time.Hour {
|
||||
return newagentshared.QuickNotePriorityImportantUrgent
|
||||
}
|
||||
return newagentshared.QuickNotePriorityImportantNotUrgent
|
||||
}
|
||||
return newagentshared.QuickNotePrioritySimpleNotImportant
|
||||
}
|
||||
|
||||
// NewQuickNoteToolHandler 创建 quick_note_create 工具的 handler 闭包。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责参数校验、时间解析、优先级推断、调 deps 写库、组装返回;
|
||||
// 2. 不负责 LLM 交互和会话管理。
|
||||
// 3. state 参数忽略——随口记不需要 ScheduleState,已注册到 scheduleFreeTools。
|
||||
func NewQuickNoteToolHandler(deps QuickNoteDeps) ToolHandler {
|
||||
return func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
_ = state
|
||||
|
||||
// 1. 提取 _user_id(由 execute 节点在调用前注入)。
|
||||
userID := 0
|
||||
if uid, ok := args["_user_id"].(int); ok {
|
||||
userID = uid
|
||||
}
|
||||
if userID <= 0 {
|
||||
return "工具调用失败:无法识别用户身份。"
|
||||
}
|
||||
|
||||
// 2. 提取必填参数 title。
|
||||
title := ""
|
||||
if t, ok := args["title"].(string); ok {
|
||||
title = strings.TrimSpace(t)
|
||||
}
|
||||
if title == "" {
|
||||
return "工具调用失败:缺少必填参数 title(任务标题)。"
|
||||
}
|
||||
|
||||
// 3. 提取可选参数 deadline_at,复用旧链路时间解析能力。
|
||||
var deadline *time.Time
|
||||
if raw, ok := args["deadline_at"].(string); ok {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw != "" {
|
||||
// 调用目的:复用旧链路成熟的中文相对时间解析器,支持"明天下午3点"等格式。
|
||||
parsed, err := newagentshared.ParseOptionalDeadline(raw)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("工具调用失败:截止时间格式无法解析(%s)。支持格式:2026-04-20 18:00、明天下午3点、下周一上午9点。", err)
|
||||
}
|
||||
deadline = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 提取可选参数 priority_group;未提供时按截止时间自动推断。
|
||||
priorityGroup := 0
|
||||
if pg, ok := args["priority_group"].(float64); ok {
|
||||
priorityGroup = int(pg)
|
||||
}
|
||||
if !newagentshared.IsValidTaskPriority(priorityGroup) {
|
||||
priorityGroup = quickNoteFallbackPriority(deadline)
|
||||
}
|
||||
|
||||
// 5. 调用依赖写库。
|
||||
taskID, err := deps.CreateTask(userID, title, priorityGroup, deadline)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("工具调用失败:写入任务时出错(%s)。", err)
|
||||
}
|
||||
if taskID <= 0 {
|
||||
return "工具调用失败:写入任务后未返回有效 task_id。"
|
||||
}
|
||||
|
||||
// 6. 组装结构化返回,包含 banter 提示引导 LLM 自然生成调侃。
|
||||
priorityLabel := newagentshared.PriorityLabelCN(priorityGroup)
|
||||
deadlineStr := ""
|
||||
if deadline != nil {
|
||||
deadlineStr = deadline.In(newagentshared.ShanghaiLocation()).Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
result := QuickNoteCreateResult{
|
||||
TaskID: taskID,
|
||||
Title: title,
|
||||
PriorityLabel: priorityLabel,
|
||||
DeadlineAt: deadlineStr,
|
||||
}
|
||||
|
||||
// 6.1 成功事实 + banter 提示:通过工具返回值引导 ReAct LLM 在 speak 中自然加入轻松跟进。
|
||||
if deadlineStr != "" {
|
||||
result.Message = fmt.Sprintf("已记录:%s(%s,截止 %s)。回复时请用轻松友好的语气,加一句与任务内容相关的俏皮话(不超过30字)。",
|
||||
title, priorityLabel, deadlineStr)
|
||||
} else {
|
||||
result.Message = fmt.Sprintf("已记录:%s(%s)。回复时请用轻松友好的语气,加一句与任务内容相关的俏皮话(不超过30字)。",
|
||||
title, priorityLabel)
|
||||
}
|
||||
|
||||
jsonBytes, marshalErr := json.Marshal(result)
|
||||
if marshalErr != nil {
|
||||
// 6.2 JSON 序列化失败时降级为纯文本,确保 LLM 仍能拿到关键信息。
|
||||
return result.Message
|
||||
}
|
||||
return string(jsonBytes)
|
||||
}
|
||||
}
|
||||
@@ -52,14 +52,7 @@ type ToolRegistry struct {
|
||||
// 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,
|
||||
}
|
||||
var temporaryDisabledTools = map[string]bool{}
|
||||
|
||||
// IsTemporarilyDisabledTool 判断工具是否在当前阶段被临时禁用。
|
||||
func IsTemporarilyDisabledTool(name string) bool {
|
||||
@@ -232,8 +225,6 @@ var writeTools = map[string]bool{
|
||||
"swap": true,
|
||||
"batch_move": true,
|
||||
"queue_apply_head_move": true,
|
||||
"spread_even": true,
|
||||
"min_context_switch": true,
|
||||
"unplace": true,
|
||||
"upsert_task_class": true,
|
||||
}
|
||||
@@ -244,8 +235,6 @@ var scheduleMutationTools = map[string]bool{
|
||||
"swap": true,
|
||||
"batch_move": true,
|
||||
"queue_apply_head_move": true,
|
||||
"spread_even": true,
|
||||
"min_context_switch": true,
|
||||
"unplace": true,
|
||||
}
|
||||
|
||||
@@ -368,30 +357,6 @@ func registerScheduleReadTools(r *ToolRegistry) {
|
||||
}
|
||||
|
||||
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",
|
||||
"分析学习节奏与切换情况。",
|
||||
@@ -400,14 +365,6 @@ func registerScheduleAnalyzeTools(r *ToolRegistry) {
|
||||
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,判断当前是否还值得继续优化,并给出候选。",
|
||||
@@ -503,30 +460,6 @@ func registerScheduleMutationTools(r *ToolRegistry) {
|
||||
return schedule.QueueSkipHead(state, args)
|
||||
},
|
||||
)
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
|
||||
}
|
||||
return schedule.MinContextSwitch(state, taskIDs)
|
||||
},
|
||||
)
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
|
||||
}
|
||||
return schedule.SpreadEven(state, taskIDs, args)
|
||||
},
|
||||
)
|
||||
r.Register(
|
||||
"unplace",
|
||||
"将一个已落位任务移除,恢复为待安排状态。task_id 必填。",
|
||||
|
||||
@@ -165,26 +165,6 @@ type analyzeHealthMetrics struct {
|
||||
CanClose bool `json:"can_close"`
|
||||
}
|
||||
|
||||
// AnalyzeLoad 已退出主动优化主链路。
|
||||
func AnalyzeLoad(state *ScheduleState, args map[string]any) string {
|
||||
return encodeAnalyzeFailure("analyze_load", "deprecated", "analyze_load 已退出主动优化链路")
|
||||
}
|
||||
|
||||
// AnalyzeSubjects 已被 analyze_rhythm 吸收。
|
||||
func AnalyzeSubjects(state *ScheduleState, args map[string]any) string {
|
||||
return encodeAnalyzeFailure("analyze_subjects", "deprecated", "analyze_subjects 已被 analyze_rhythm 吸收")
|
||||
}
|
||||
|
||||
// AnalyzeContext 已被 analyze_rhythm 吸收。
|
||||
func AnalyzeContext(state *ScheduleState, args map[string]any) string {
|
||||
return encodeAnalyzeFailure("analyze_context", "deprecated", "analyze_context 已被 analyze_rhythm 吸收")
|
||||
}
|
||||
|
||||
// AnalyzeTolerance 已退出主动优化主链路。
|
||||
func AnalyzeTolerance(state *ScheduleState, args map[string]any) string {
|
||||
return encodeAnalyzeFailure("analyze_tolerance", "deprecated", "analyze_tolerance 已退出主动优化链路")
|
||||
}
|
||||
|
||||
// AnalyzeRhythm 输出认知节奏层面的结构化观察。
|
||||
func AnalyzeRhythm(state *ScheduleState, args map[string]any) string {
|
||||
if state == nil {
|
||||
@@ -958,164 +938,6 @@ func buildSemanticProfileIssues(metrics analyzeSemanticProfileMetrics) []analyze
|
||||
}}
|
||||
}
|
||||
|
||||
func buildAnalyzeHealthDecision(
|
||||
state *ScheduleState,
|
||||
snapshot analyzeHealthSnapshot,
|
||||
) analyzeHealthDecision {
|
||||
base := buildAnalyzeHealthDecisionBase(state, snapshot)
|
||||
decision := analyzeHealthDecision{
|
||||
ShouldContinueOptimize: base.ShouldContinueOptimize,
|
||||
PrimaryProblem: base.PrimaryProblem,
|
||||
ProblemScope: base.ProblemScope,
|
||||
IsForcedImperfection: base.IsForcedImperfection,
|
||||
RecommendedOperation: base.RecommendedOperation,
|
||||
ImprovementSignal: buildHealthImprovementSignal(
|
||||
snapshot.Rhythm,
|
||||
snapshot.Tightness,
|
||||
base.ProblemScope,
|
||||
base.RecommendedOperation,
|
||||
snapshot.Profile,
|
||||
snapshot.Feasibility,
|
||||
),
|
||||
}
|
||||
|
||||
// 1. 只有“高认知相邻”这类当前 P1 真正能靠确定性候选修复的问题,才进入候选枚举。
|
||||
// 2. 若所有合法候选都只是平移/无增益/恶化,则直接回到 close,避免把 LLM 逼成苦力工。
|
||||
// 3. close 永远保留为兜底选项,让 LLM 可以自然收口,而不是为了完成任务感继续乱挪。
|
||||
problem, ok := pickPrimaryHealthProblem(state, snapshot)
|
||||
if !ok || problem.Kind != healthProblemHeavyAdjacent || problem.Pair == nil {
|
||||
decision.Candidates = []analyzeHealthCandidate{
|
||||
buildHealthCloseCandidate("保持当前安排并收口:当前没有可继续处理的候选认知问题。", snapshot, base),
|
||||
}
|
||||
decision.ShouldContinueOptimize = false
|
||||
decision.RecommendedOperation = "close"
|
||||
decision.ImprovementSignal = buildHealthImprovementSignal(
|
||||
snapshot.Rhythm,
|
||||
snapshot.Tightness,
|
||||
decision.ProblemScope,
|
||||
decision.RecommendedOperation,
|
||||
snapshot.Profile,
|
||||
snapshot.Feasibility,
|
||||
)
|
||||
return decision
|
||||
}
|
||||
|
||||
beneficial := buildHealthCandidatesForProblem(state, snapshot, problem)
|
||||
if len(beneficial) == 0 {
|
||||
decision.Candidates = []analyzeHealthCandidate{
|
||||
buildHealthCloseCandidate("保持当前安排并收口:当前所有合法 move / swap 都只会平移、无增益或恶化问题。", snapshot, base),
|
||||
}
|
||||
decision.ShouldContinueOptimize = false
|
||||
decision.RecommendedOperation = "close"
|
||||
if snapshot.Tightness.TightnessLevel == "locked" || snapshot.Tightness.TightnessLevel == "tight" {
|
||||
decision.IsForcedImperfection = true
|
||||
}
|
||||
decision.ImprovementSignal = buildHealthImprovementSignal(
|
||||
snapshot.Rhythm,
|
||||
snapshot.Tightness,
|
||||
decision.ProblemScope,
|
||||
decision.RecommendedOperation,
|
||||
snapshot.Profile,
|
||||
snapshot.Feasibility,
|
||||
)
|
||||
return decision
|
||||
}
|
||||
|
||||
decision.Candidates = append(decision.Candidates, beneficial...)
|
||||
decision.Candidates = append(decision.Candidates,
|
||||
buildHealthCloseCandidate("如果不想继续挪动,也可以保持当前安排并直接收口。", snapshot, base),
|
||||
)
|
||||
decision.ShouldContinueOptimize = true
|
||||
decision.RecommendedOperation = strings.TrimSpace(beneficial[0].Tool)
|
||||
decision.ImprovementSignal = buildHealthImprovementSignal(
|
||||
snapshot.Rhythm,
|
||||
snapshot.Tightness,
|
||||
decision.ProblemScope,
|
||||
decision.RecommendedOperation,
|
||||
snapshot.Profile,
|
||||
snapshot.Feasibility,
|
||||
)
|
||||
return decision
|
||||
}
|
||||
|
||||
func pickPrimaryRhythmProblem(
|
||||
rhythm analyzeRhythmMetrics,
|
||||
tightness analyzeTightnessMetrics,
|
||||
) (summary string, scope *analyzeProblemScope, operation string, ok bool) {
|
||||
type rhythmCandidate struct {
|
||||
score int
|
||||
summary string
|
||||
scope *analyzeProblemScope
|
||||
preferSwap bool
|
||||
}
|
||||
|
||||
candidates := make([]rhythmCandidate, 0, len(rhythm.Days)*2)
|
||||
for _, day := range rhythm.Days {
|
||||
if day.HeavyAdjacent && !shouldTreatHeavyAdjacencyAsAcceptable(rhythm, day) {
|
||||
score := 300 + day.SwitchCount*8 + int(day.Fragmentation*20)
|
||||
candidates = append(candidates, rhythmCandidate{
|
||||
score: score,
|
||||
summary: fmt.Sprintf("第 %d 天存在高认知强度任务相邻,学起来会发紧", day.DayIndex),
|
||||
scope: &analyzeProblemScope{DayRange: []int{day.DayIndex}},
|
||||
preferSwap: true,
|
||||
})
|
||||
}
|
||||
if day.SwitchCount >= 5 && day.Fragmentation >= 0.75 {
|
||||
score := 220 + day.SwitchCount*10 + int(day.Fragmentation*100)
|
||||
candidates = append(candidates, rhythmCandidate{
|
||||
score: score,
|
||||
summary: fmt.Sprintf("第 %d 天切换次数偏多,学习节奏明显发碎", day.DayIndex),
|
||||
scope: &analyzeProblemScope{DayRange: []int{day.DayIndex}},
|
||||
preferSwap: false,
|
||||
})
|
||||
}
|
||||
if day.MaxBlock >= 5 {
|
||||
score := 140 + day.MaxBlock*10
|
||||
candidates = append(candidates, rhythmCandidate{
|
||||
score: score,
|
||||
summary: fmt.Sprintf("第 %d 天连续同科目学习块过长,节奏略显单一", day.DayIndex),
|
||||
scope: &analyzeProblemScope{DayRange: []int{day.DayIndex}},
|
||||
preferSwap: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return "", nil, "close", false
|
||||
}
|
||||
sort.SliceStable(candidates, func(i, j int) bool {
|
||||
if candidates[i].score != candidates[j].score {
|
||||
return candidates[i].score > candidates[j].score
|
||||
}
|
||||
leftDay := 1 << 30
|
||||
rightDay := 1 << 30
|
||||
if candidates[i].scope != nil && len(candidates[i].scope.DayRange) > 0 {
|
||||
leftDay = candidates[i].scope.DayRange[0]
|
||||
}
|
||||
if candidates[j].scope != nil && len(candidates[j].scope.DayRange) > 0 {
|
||||
rightDay = candidates[j].scope.DayRange[0]
|
||||
}
|
||||
return leftDay < rightDay
|
||||
})
|
||||
best := candidates[0]
|
||||
operation = chooseHealthOperation(tightness, best.preferSwap)
|
||||
return best.summary, best.scope, operation, true
|
||||
}
|
||||
|
||||
func chooseHealthOperation(tightness analyzeTightnessMetrics, preferSwap bool) string {
|
||||
switch {
|
||||
case tightness.TightnessLevel == "locked":
|
||||
return "close"
|
||||
case preferSwap && tightness.CrossClassSwapOptions > 0:
|
||||
return "swap"
|
||||
case tightness.LocallyMovableTaskCount > 0:
|
||||
return "move"
|
||||
case tightness.CrossClassSwapOptions > 0:
|
||||
return "swap"
|
||||
default:
|
||||
return "close"
|
||||
}
|
||||
}
|
||||
|
||||
func shouldTreatHeavyAdjacencyAsAcceptable(rhythm analyzeRhythmMetrics, day analyzeContextDay) bool {
|
||||
// 1. 若整体切换本来就少、同类型切换占比很高,说明当前节奏更像“同类硬课顺着学”,
|
||||
// 这类情况不该因为“高认知相邻”四个字就被反复优化。
|
||||
|
||||
@@ -1,707 +0,0 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
compositelogic "github.com/LoveLosita/smartflow/backend/logic"
|
||||
)
|
||||
|
||||
var spreadEvenAllowedArgs = []string{
|
||||
"task_ids",
|
||||
"task_id",
|
||||
"limit",
|
||||
"allow_embed",
|
||||
"day",
|
||||
"day_start",
|
||||
"day_end",
|
||||
"day_scope",
|
||||
"day_of_week",
|
||||
"week",
|
||||
"week_filter",
|
||||
"week_from",
|
||||
"week_to",
|
||||
"slot_type",
|
||||
"slot_types",
|
||||
"exclude_sections",
|
||||
"after_section",
|
||||
"before_section",
|
||||
}
|
||||
|
||||
// minContextSnapshot 记录任务在复合重排前后的最小快照,用于输出摘要。
|
||||
type minContextSnapshot struct {
|
||||
StateID int
|
||||
Name string
|
||||
ContextTag string
|
||||
Slot TaskSlot
|
||||
}
|
||||
|
||||
// refineTaskCandidate 是复合规划器使用的任务输入。
|
||||
type refineTaskCandidate struct {
|
||||
TaskID int
|
||||
Week int
|
||||
DayOfWeek int
|
||||
SectionFrom int
|
||||
SectionTo int
|
||||
Name string
|
||||
ContextTag string
|
||||
OriginRank int
|
||||
}
|
||||
|
||||
// compositeIDMapper 负责维护 state_id 与 logic 规划入参 ID 的双向映射。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 当前阶段使用等值映射(logicID=stateID),保证行为不变;
|
||||
// 2. 保留独立适配层,后续若切到真实 task_item_id,只需改这里;
|
||||
// 3. 通过双向映射保证“入参转换 + 结果回填”一致。
|
||||
type compositeIDMapper struct {
|
||||
stateToLogic map[int]int
|
||||
logicToState map[int]int
|
||||
}
|
||||
|
||||
// buildCompositeIDMapper 构建并校验本轮复合工具的 ID 映射。
|
||||
func buildCompositeIDMapper(stateIDs []int) (*compositeIDMapper, error) {
|
||||
mapper := &compositeIDMapper{
|
||||
stateToLogic: make(map[int]int, len(stateIDs)),
|
||||
logicToState: make(map[int]int, len(stateIDs)),
|
||||
}
|
||||
for _, stateID := range stateIDs {
|
||||
if stateID <= 0 {
|
||||
return nil, fmt.Errorf("存在非法 state_id=%d", stateID)
|
||||
}
|
||||
if _, exists := mapper.stateToLogic[stateID]; exists {
|
||||
return nil, fmt.Errorf("state_id=%d 重复", stateID)
|
||||
}
|
||||
// 当前迁移阶段采用等值映射,先把“映射机制”跑通。
|
||||
logicID := stateID
|
||||
mapper.stateToLogic[stateID] = logicID
|
||||
mapper.logicToState[logicID] = stateID
|
||||
}
|
||||
return mapper, nil
|
||||
}
|
||||
|
||||
// MinContextSwitch 在给定任务集合内重排 suggested 任务,尽量减少上下文切换次数。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只处理“已落位的 suggested 任务”重排,不负责粗排;
|
||||
// 2. 仅在给定 task_ids 集合内部重排,不改动集合外任务;
|
||||
// 3. 采用原子提交:任一校验失败则整体不生效。
|
||||
func MinContextSwitch(state *ScheduleState, taskIDs []int) string {
|
||||
if state == nil {
|
||||
return "减少上下文切换失败:日程状态为空。"
|
||||
}
|
||||
|
||||
// 1. 收集任务并做前置校验,确保规划输入可用。
|
||||
plannerTasks, beforeByID, excludeIDs, idMapper, err := collectCompositePlannerTasks(state, taskIDs, "减少上下文切换")
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
logicTasks, err := toLogicPlannerTasks(plannerTasks, idMapper)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
|
||||
}
|
||||
|
||||
// 2. 该工具固定在“当前任务已占坑位集合”内重排,不向外扩张候选位。
|
||||
currentSlots := buildCurrentSlotsFromPlannerTasks(logicTasks)
|
||||
plannedMoves, err := compositelogic.PlanMinContextSwitchMoves(logicTasks, currentSlots, compositelogic.RefineCompositePlanOptions{})
|
||||
if err != nil {
|
||||
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
|
||||
}
|
||||
|
||||
// 3. 映射回工具态坐标并在提交前做完整校验。
|
||||
afterByID, err := buildAfterSnapshotsFromPlannedMoves(state, beforeByID, plannedMoves, idMapper)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
|
||||
}
|
||||
for taskID, after := range afterByID {
|
||||
before := beforeByID[taskID]
|
||||
if err := validateDay(state, after.Slot.Day); err != nil {
|
||||
return fmt.Sprintf("减少上下文切换失败:任务 [%d]%s 目标天非法:%s。", before.StateID, before.Name, err.Error())
|
||||
}
|
||||
if err := validateSlotRange(after.Slot.SlotStart, after.Slot.SlotEnd); err != nil {
|
||||
return fmt.Sprintf("减少上下文切换失败:任务 [%d]%s 目标节次非法:%s。", before.StateID, before.Name, err.Error())
|
||||
}
|
||||
if conflict := findConflict(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd, excludeIDs...); conflict != nil {
|
||||
return fmt.Sprintf(
|
||||
"减少上下文切换失败:任务 [%d]%s 目标位置 %s 与 [%d]%s 冲突。",
|
||||
before.StateID,
|
||||
before.Name,
|
||||
formatDaySlotLabel(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd),
|
||||
conflict.StateID,
|
||||
conflict.Name,
|
||||
)
|
||||
}
|
||||
}
|
||||
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()
|
||||
for taskID, after := range afterByID {
|
||||
task := clone.TaskByStateID(taskID)
|
||||
if task == nil {
|
||||
return fmt.Sprintf("减少上下文切换失败:任务ID %d 在提交阶段不存在。", taskID)
|
||||
}
|
||||
task.Slots = []TaskSlot{after.Slot}
|
||||
}
|
||||
state.Tasks = clone.Tasks
|
||||
|
||||
beforeOrdered := sortMinContextSnapshots(beforeByID)
|
||||
afterOrdered := sortMinContextSnapshots(afterByID)
|
||||
beforeSwitches := countMinContextSwitches(beforeOrdered)
|
||||
afterSwitches := countMinContextSwitches(afterOrdered)
|
||||
|
||||
changedLines := make([]string, 0, len(beforeOrdered))
|
||||
affectedDays := make(map[int]bool, len(beforeOrdered)*2)
|
||||
for _, before := range beforeOrdered {
|
||||
after := afterByID[before.StateID]
|
||||
if sameTaskSlot(before.Slot, after.Slot) {
|
||||
continue
|
||||
}
|
||||
changedLines = append(changedLines, fmt.Sprintf(
|
||||
" [%d]%s:%s -> %s",
|
||||
before.StateID,
|
||||
before.Name,
|
||||
formatDaySlotLabel(state, before.Slot.Day, before.Slot.SlotStart, before.Slot.SlotEnd),
|
||||
formatDaySlotLabel(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd),
|
||||
))
|
||||
affectedDays[before.Slot.Day] = true
|
||||
affectedDays[after.Slot.Day] = true
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf(
|
||||
"最少上下文切换重排完成:共处理 %d 个任务,上下文切换次数 %d -> %d。\n",
|
||||
len(beforeByID), beforeSwitches, afterSwitches,
|
||||
))
|
||||
if len(changedLines) == 0 {
|
||||
sb.WriteString("当前任务顺序已是较优结果,无需调整。")
|
||||
return sb.String()
|
||||
}
|
||||
sb.WriteString("本次调整:\n")
|
||||
for _, line := range changedLines {
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
for _, day := range sortedKeys(affectedDays) {
|
||||
sb.WriteString(formatDayOccupancy(state, day) + "\n")
|
||||
}
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// SpreadEven 在给定任务集合内执行“均匀化铺开”。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 仅处理 suggested 且已落位任务;
|
||||
// 2. 先按筛选条件收集候选坑位,再调用确定性规划器;
|
||||
// 3. 通过统一校验后原子提交,失败不落地。
|
||||
func SpreadEven(state *ScheduleState, taskIDs []int, args map[string]any) string {
|
||||
if state == nil {
|
||||
return "均匀化调整失败:日程状态为空。"
|
||||
}
|
||||
// 0. 参数白名单校验:未知字段直接失败,避免静默忽略导致候选范围漂移。
|
||||
if err := validateToolArgsStrict(args, spreadEvenAllowedArgs); err != nil {
|
||||
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
|
||||
}
|
||||
|
||||
// 1. 先做任务侧校验,避免后续规划在脏输入上执行。
|
||||
plannerTasks, beforeByID, excludeIDs, idMapper, err := collectCompositePlannerTasks(state, taskIDs, "均匀化调整")
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
logicTasks, err := toLogicPlannerTasks(plannerTasks, idMapper)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
|
||||
}
|
||||
|
||||
// 2. 按跨度需求收集候选坑位,确保每类跨度都有可用池。
|
||||
spanNeed := make(map[int]int, len(logicTasks))
|
||||
for _, task := range logicTasks {
|
||||
spanNeed[task.SectionTo-task.SectionFrom+1]++
|
||||
}
|
||||
candidateSlots, err := collectSpreadEvenCandidateSlotsBySpan(state, args, spanNeed)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
|
||||
}
|
||||
|
||||
// 3. 用“范围内既有负载”作为打分基线,让结果更接近均匀分布。
|
||||
dayLoadBaseline := buildSpreadEvenDayLoadBaseline(state, excludeIDs, candidateSlots)
|
||||
plannedMoves, err := compositelogic.PlanEvenSpreadMoves(logicTasks, candidateSlots, compositelogic.RefineCompositePlanOptions{
|
||||
ExistingDayLoad: dayLoadBaseline,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
|
||||
}
|
||||
|
||||
// 4. 回填 + 校验 + 原子提交。
|
||||
afterByID, err := buildAfterSnapshotsFromPlannedMoves(state, beforeByID, plannedMoves, idMapper)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
|
||||
}
|
||||
for taskID, after := range afterByID {
|
||||
before := beforeByID[taskID]
|
||||
if err := validateDay(state, after.Slot.Day); err != nil {
|
||||
return fmt.Sprintf("均匀化调整失败:任务 [%d]%s 目标天非法:%s。", before.StateID, before.Name, err.Error())
|
||||
}
|
||||
if err := validateSlotRange(after.Slot.SlotStart, after.Slot.SlotEnd); err != nil {
|
||||
return fmt.Sprintf("均匀化调整失败:任务 [%d]%s 目标节次非法:%s。", before.StateID, before.Name, err.Error())
|
||||
}
|
||||
if conflict := findConflict(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd, excludeIDs...); conflict != nil {
|
||||
return fmt.Sprintf(
|
||||
"均匀化调整失败:任务 [%d]%s 目标位置 %s 与 [%d]%s 冲突。",
|
||||
before.StateID,
|
||||
before.Name,
|
||||
formatDaySlotLabel(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd),
|
||||
conflict.StateID,
|
||||
conflict.Name,
|
||||
)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
task := clone.TaskByStateID(taskID)
|
||||
if task == nil {
|
||||
return fmt.Sprintf("均匀化调整失败:任务ID %d 在提交阶段不存在。", taskID)
|
||||
}
|
||||
task.Slots = []TaskSlot{after.Slot}
|
||||
}
|
||||
state.Tasks = clone.Tasks
|
||||
|
||||
beforeOrdered := sortMinContextSnapshots(beforeByID)
|
||||
changedLines := make([]string, 0, len(beforeOrdered))
|
||||
affectedDays := make(map[int]bool, len(beforeOrdered)*2)
|
||||
for _, before := range beforeOrdered {
|
||||
after := afterByID[before.StateID]
|
||||
if sameTaskSlot(before.Slot, after.Slot) {
|
||||
continue
|
||||
}
|
||||
changedLines = append(changedLines, fmt.Sprintf(
|
||||
" [%d]%s:%s -> %s",
|
||||
before.StateID,
|
||||
before.Name,
|
||||
formatDaySlotLabel(state, before.Slot.Day, before.Slot.SlotStart, before.Slot.SlotEnd),
|
||||
formatDaySlotLabel(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd),
|
||||
))
|
||||
affectedDays[before.Slot.Day] = true
|
||||
affectedDays[after.Slot.Day] = true
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf(
|
||||
"均匀化调整完成:共处理 %d 个任务,候选坑位 %d 个。\n",
|
||||
len(beforeByID), len(candidateSlots),
|
||||
))
|
||||
if len(changedLines) == 0 {
|
||||
sb.WriteString("规划结果与当前落位一致,无需调整。")
|
||||
return sb.String()
|
||||
}
|
||||
sb.WriteString("本次调整:\n")
|
||||
for _, line := range changedLines {
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
for _, day := range sortedKeys(affectedDays) {
|
||||
sb.WriteString(formatDayOccupancy(state, day) + "\n")
|
||||
}
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
func ParseMinContextSwitchTaskIDs(args map[string]any) ([]int, error) {
|
||||
return ParseCompositeTaskIDs(args)
|
||||
}
|
||||
|
||||
func ParseSpreadEvenTaskIDs(args map[string]any) ([]int, error) {
|
||||
return ParseCompositeTaskIDs(args)
|
||||
}
|
||||
|
||||
func ParseCompositeTaskIDs(args map[string]any) ([]int, error) {
|
||||
if ids, ok := ArgsIntSlice(args, "task_ids"); ok && len(ids) > 0 {
|
||||
return ids, nil
|
||||
}
|
||||
if id, ok := ArgsInt(args, "task_id"); ok {
|
||||
return []int{id}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("缺少必填参数 task_ids(兼容单值 task_id)")
|
||||
}
|
||||
|
||||
// collectCompositePlannerTasks 统一收集复合工具输入任务,并做“可移动 suggested”校验。
|
||||
func collectCompositePlannerTasks(
|
||||
state *ScheduleState,
|
||||
taskIDs []int,
|
||||
toolLabel string,
|
||||
) ([]refineTaskCandidate, map[int]minContextSnapshot, []int, *compositeIDMapper, error) {
|
||||
normalizedIDs := uniquePositiveInts(taskIDs)
|
||||
if len(normalizedIDs) < 2 {
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s失败:task_ids 至少需要 2 个有效任务 ID", toolLabel)
|
||||
}
|
||||
|
||||
idMapper, err := buildCompositeIDMapper(normalizedIDs)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s失败:ID 映射构建失败:%s", toolLabel, err.Error())
|
||||
}
|
||||
|
||||
plannerTasks := make([]refineTaskCandidate, 0, len(normalizedIDs))
|
||||
beforeByID := make(map[int]minContextSnapshot, len(normalizedIDs))
|
||||
excludeIDs := make([]int, 0, len(normalizedIDs))
|
||||
|
||||
for rank, taskID := range normalizedIDs {
|
||||
task := state.TaskByStateID(taskID)
|
||||
if task == nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s失败:任务ID %d 不存在", toolLabel, taskID)
|
||||
}
|
||||
if !IsSuggestedTask(*task) {
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 不是 suggested 任务,仅 suggested 可参与该工具", toolLabel, task.StateID, task.Name)
|
||||
}
|
||||
if err := checkLocked(*task); err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s失败:%s", toolLabel, err.Error())
|
||||
}
|
||||
if len(task.Slots) != 1 {
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 当前包含 %d 段时段,暂不支持该形态", toolLabel, task.StateID, task.Name, len(task.Slots))
|
||||
}
|
||||
|
||||
slot := task.Slots[0]
|
||||
if err := validateDay(state, slot.Day); err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 的时段非法:%s", toolLabel, task.StateID, task.Name, err.Error())
|
||||
}
|
||||
if err := validateSlotRange(slot.SlotStart, slot.SlotEnd); err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 的节次非法:%s", toolLabel, task.StateID, task.Name, err.Error())
|
||||
}
|
||||
week, dayOfWeek, ok := state.DayToWeekDay(slot.Day)
|
||||
if !ok {
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 的 day=%d 无法映射到 week/day_of_week", toolLabel, task.StateID, task.Name, slot.Day)
|
||||
}
|
||||
|
||||
contextTag := normalizeMinContextTag(*task)
|
||||
beforeByID[task.StateID] = minContextSnapshot{
|
||||
StateID: task.StateID,
|
||||
Name: task.Name,
|
||||
ContextTag: contextTag,
|
||||
Slot: slot,
|
||||
}
|
||||
excludeIDs = append(excludeIDs, task.StateID)
|
||||
plannerTasks = append(plannerTasks, refineTaskCandidate{
|
||||
TaskID: task.StateID,
|
||||
Week: week,
|
||||
DayOfWeek: dayOfWeek,
|
||||
SectionFrom: slot.SlotStart,
|
||||
SectionTo: slot.SlotEnd,
|
||||
Name: strings.TrimSpace(task.Name),
|
||||
ContextTag: contextTag,
|
||||
OriginRank: rank + 1,
|
||||
})
|
||||
}
|
||||
|
||||
return plannerTasks, beforeByID, excludeIDs, idMapper, nil
|
||||
}
|
||||
|
||||
// toLogicPlannerTasks 将工具层任务结构映射为 logic 规划器输入。
|
||||
func toLogicPlannerTasks(tasks []refineTaskCandidate, idMapper *compositeIDMapper) ([]compositelogic.RefineTaskCandidate, error) {
|
||||
if len(tasks) == 0 {
|
||||
return nil, fmt.Errorf("任务列表为空")
|
||||
}
|
||||
if idMapper == nil {
|
||||
return nil, fmt.Errorf("ID 映射为空")
|
||||
}
|
||||
result := make([]compositelogic.RefineTaskCandidate, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
logicID, ok := idMapper.stateToLogic[task.TaskID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("任务 state_id=%d 缺少 logic 映射", task.TaskID)
|
||||
}
|
||||
result = append(result, compositelogic.RefineTaskCandidate{
|
||||
TaskItemID: logicID,
|
||||
Week: task.Week,
|
||||
DayOfWeek: task.DayOfWeek,
|
||||
SectionFrom: task.SectionFrom,
|
||||
SectionTo: task.SectionTo,
|
||||
Name: task.Name,
|
||||
ContextTag: task.ContextTag,
|
||||
OriginRank: task.OriginRank,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildCurrentSlotsFromPlannerTasks(tasks []compositelogic.RefineTaskCandidate) []compositelogic.RefineSlotCandidate {
|
||||
slots := make([]compositelogic.RefineSlotCandidate, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
slots = append(slots, compositelogic.RefineSlotCandidate{
|
||||
Week: task.Week,
|
||||
DayOfWeek: task.DayOfWeek,
|
||||
SectionFrom: task.SectionFrom,
|
||||
SectionTo: task.SectionTo,
|
||||
})
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
||||
func buildAfterSnapshotsFromPlannedMoves(
|
||||
state *ScheduleState,
|
||||
beforeByID map[int]minContextSnapshot,
|
||||
plannedMoves []compositelogic.RefineMovePlanItem,
|
||||
idMapper *compositeIDMapper,
|
||||
) (map[int]minContextSnapshot, error) {
|
||||
if len(plannedMoves) == 0 {
|
||||
return nil, fmt.Errorf("规划结果为空")
|
||||
}
|
||||
if idMapper == nil {
|
||||
return nil, fmt.Errorf("ID 映射为空")
|
||||
}
|
||||
|
||||
moveByID := make(map[int]compositelogic.RefineMovePlanItem, len(plannedMoves))
|
||||
for _, move := range plannedMoves {
|
||||
stateID, ok := idMapper.logicToState[move.TaskItemID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("规划结果包含未知 logic 任务 id=%d", move.TaskItemID)
|
||||
}
|
||||
if _, exists := moveByID[stateID]; exists {
|
||||
return nil, fmt.Errorf("规划结果包含重复任务 id=%d", stateID)
|
||||
}
|
||||
moveByID[stateID] = move
|
||||
}
|
||||
|
||||
afterByID := make(map[int]minContextSnapshot, len(beforeByID))
|
||||
for taskID, before := range beforeByID {
|
||||
move, ok := moveByID[taskID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("规划结果不完整:缺少任务 id=%d", taskID)
|
||||
}
|
||||
day, ok := state.WeekDayToDay(move.ToWeek, move.ToDay)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("任务 id=%d 目标 week/day 无法映射到 day_index:W%dD%d", taskID, move.ToWeek, move.ToDay)
|
||||
}
|
||||
afterByID[taskID] = minContextSnapshot{
|
||||
StateID: before.StateID,
|
||||
Name: before.Name,
|
||||
ContextTag: before.ContextTag,
|
||||
Slot: TaskSlot{
|
||||
Day: day,
|
||||
SlotStart: move.ToSectionFrom,
|
||||
SlotEnd: move.ToSectionTo,
|
||||
},
|
||||
}
|
||||
}
|
||||
return afterByID, nil
|
||||
}
|
||||
|
||||
func collectSpreadEvenCandidateSlotsBySpan(
|
||||
state *ScheduleState,
|
||||
args map[string]any,
|
||||
spanNeed map[int]int,
|
||||
) ([]compositelogic.RefineSlotCandidate, error) {
|
||||
if len(spanNeed) == 0 {
|
||||
return nil, fmt.Errorf("未识别到任务跨度需求")
|
||||
}
|
||||
|
||||
spans := make([]int, 0, len(spanNeed))
|
||||
for span := range spanNeed {
|
||||
spans = append(spans, span)
|
||||
}
|
||||
sort.Ints(spans)
|
||||
|
||||
allSlots := make([]compositelogic.RefineSlotCandidate, 0, 16)
|
||||
seen := make(map[string]struct{}, 64)
|
||||
for _, span := range spans {
|
||||
required := spanNeed[span]
|
||||
queryArgs := buildSpreadEvenSlotQueryArgs(args, span, required)
|
||||
raw := QueryAvailableSlots(state, queryArgs)
|
||||
|
||||
var failed struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(raw), &failed)
|
||||
if strings.TrimSpace(failed.Error) != "" {
|
||||
return nil, fmt.Errorf("查询跨度=%d 的候选坑位失败:%s", span, strings.TrimSpace(failed.Error))
|
||||
}
|
||||
|
||||
var payload queryAvailableSlotsResult
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return nil, fmt.Errorf("解析跨度=%d 的候选坑位结果失败:%v", span, err)
|
||||
}
|
||||
if len(payload.Slots) < required {
|
||||
return nil, fmt.Errorf("跨度=%d 可用坑位不足:required=%d, got=%d", span, required, len(payload.Slots))
|
||||
}
|
||||
|
||||
for _, slot := range payload.Slots {
|
||||
key := fmt.Sprintf("%d-%d-%d-%d", slot.Week, slot.DayOfWeek, slot.SlotStart, slot.SlotEnd)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
allSlots = append(allSlots, compositelogic.RefineSlotCandidate{
|
||||
Week: slot.Week,
|
||||
DayOfWeek: slot.DayOfWeek,
|
||||
SectionFrom: slot.SlotStart,
|
||||
SectionTo: slot.SlotEnd,
|
||||
})
|
||||
}
|
||||
}
|
||||
return allSlots, nil
|
||||
}
|
||||
|
||||
func buildSpreadEvenSlotQueryArgs(args map[string]any, span int, required int) map[string]any {
|
||||
query := make(map[string]any, 16)
|
||||
query["span"] = span
|
||||
|
||||
limit := required * 6
|
||||
if limit < required {
|
||||
limit = required
|
||||
}
|
||||
if customLimit, ok := readIntAny(args, "limit"); ok && customLimit > limit {
|
||||
limit = customLimit
|
||||
}
|
||||
query["limit"] = limit
|
||||
query["allow_embed"] = readBoolAnyWithDefault(args, true, "allow_embed", "allow_embedding")
|
||||
|
||||
for _, key := range []string{"day", "day_start", "day_end", "week", "week_from", "week_to", "day_scope", "after_section", "before_section"} {
|
||||
if value, ok := args[key]; ok {
|
||||
query[key] = value
|
||||
}
|
||||
}
|
||||
if week, ok := readIntAny(args, "to_week", "target_week", "new_week"); ok {
|
||||
query["week"] = week
|
||||
}
|
||||
if day, ok := readIntAny(args, "to_day", "target_day", "target_day_of_week", "new_day"); ok {
|
||||
query["day_of_week"] = []int{day}
|
||||
}
|
||||
|
||||
if values := uniquePositiveInts(readIntSliceAny(args, "week_filter", "weeks")); len(values) > 0 {
|
||||
query["week_filter"] = values
|
||||
}
|
||||
if values := uniqueInts(readIntSliceAny(args, "day_of_week", "days", "day_filter")); len(values) > 0 {
|
||||
query["day_of_week"] = values
|
||||
}
|
||||
if values := uniqueInts(readIntSliceAny(args, "exclude_sections", "exclude_section")); len(values) > 0 {
|
||||
query["exclude_sections"] = values
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
func buildSpreadEvenDayLoadBaseline(
|
||||
state *ScheduleState,
|
||||
excludeTaskIDs []int,
|
||||
slots []compositelogic.RefineSlotCandidate,
|
||||
) map[string]int {
|
||||
if len(slots) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
targetDays := make(map[string]struct{}, len(slots))
|
||||
for _, slot := range slots {
|
||||
targetDays[composeDayKey(slot.Week, slot.DayOfWeek)] = struct{}{}
|
||||
}
|
||||
if len(targetDays) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
excludeSet := make(map[int]struct{}, len(excludeTaskIDs))
|
||||
for _, id := range excludeTaskIDs {
|
||||
excludeSet[id] = struct{}{}
|
||||
}
|
||||
|
||||
load := make(map[string]int, len(targetDays))
|
||||
for _, task := range state.Tasks {
|
||||
if !IsSuggestedTask(task) {
|
||||
continue
|
||||
}
|
||||
if _, excluded := excludeSet[task.StateID]; excluded {
|
||||
continue
|
||||
}
|
||||
for _, slot := range task.Slots {
|
||||
week, dayOfWeek, ok := state.DayToWeekDay(slot.Day)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key := composeDayKey(week, dayOfWeek)
|
||||
if _, inTarget := targetDays[key]; !inTarget {
|
||||
continue
|
||||
}
|
||||
load[key]++
|
||||
}
|
||||
}
|
||||
return load
|
||||
}
|
||||
|
||||
func composeDayKey(week, day int) string {
|
||||
return fmt.Sprintf("%d-%d", week, day)
|
||||
}
|
||||
|
||||
func uniquePositiveInts(values []int) []int {
|
||||
seen := make(map[int]struct{}, len(values))
|
||||
result := make([]int, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeMinContextTag(task ScheduleTask) string {
|
||||
if tag := strings.TrimSpace(task.Category); tag != "" {
|
||||
return tag
|
||||
}
|
||||
if tag := strings.TrimSpace(task.Name); tag != "" {
|
||||
return tag
|
||||
}
|
||||
return "General"
|
||||
}
|
||||
|
||||
func sortMinContextSnapshots(snapshotByID map[int]minContextSnapshot) []minContextSnapshot {
|
||||
items := make([]minContextSnapshot, 0, len(snapshotByID))
|
||||
for _, item := range snapshotByID {
|
||||
items = append(items, item)
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if items[i].Slot.Day != items[j].Slot.Day {
|
||||
return items[i].Slot.Day < items[j].Slot.Day
|
||||
}
|
||||
if items[i].Slot.SlotStart != items[j].Slot.SlotStart {
|
||||
return items[i].Slot.SlotStart < items[j].Slot.SlotStart
|
||||
}
|
||||
if items[i].Slot.SlotEnd != items[j].Slot.SlotEnd {
|
||||
return items[i].Slot.SlotEnd < items[j].Slot.SlotEnd
|
||||
}
|
||||
return items[i].StateID < items[j].StateID
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
func countMinContextSwitches(ordered []minContextSnapshot) int {
|
||||
if len(ordered) < 2 {
|
||||
return 0
|
||||
}
|
||||
switches := 0
|
||||
prevTag := strings.TrimSpace(ordered[0].ContextTag)
|
||||
for i := 1; i < len(ordered); i++ {
|
||||
currentTag := strings.TrimSpace(ordered[i].ContextTag)
|
||||
if currentTag != prevTag {
|
||||
switches++
|
||||
}
|
||||
prevTag = currentTag
|
||||
}
|
||||
return switches
|
||||
}
|
||||
|
||||
func sameTaskSlot(a, b TaskSlot) bool {
|
||||
return a.Day == b.Day && a.SlotStart == b.SlotStart && a.SlotEnd == b.SlotEnd
|
||||
}
|
||||
@@ -20,7 +20,7 @@ func validateLocalOrderForSinglePlacement(state *ScheduleState, taskID int, targ
|
||||
// validateLocalOrderBatchPlacement 在“多任务同时变更”的假设下做顺序约束校验。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 先把所有候选落位一次性写入克隆态,再统一校验,避免 swap/batch/spread_even 出现伪冲突;
|
||||
// 1. 先把所有候选落位一次性写入克隆态,再统一校验,避免批量局部调整时出现伪冲突;
|
||||
// 2. 只校验 proposals 中涉及的任务,因为只要这些任务仍处于各自前驱/后继之间,就不会破坏同类整体顺序;
|
||||
// 3. 返回首个命中的中文错误,供写工具直接透传给 LLM。
|
||||
func validateLocalOrderBatchPlacement(state *ScheduleState, proposals map[int][]TaskSlot) error {
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
package newagenttools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
|
||||
)
|
||||
|
||||
// ==================== 常量 ====================
|
||||
|
||||
const (
|
||||
// defaultTaskQueryLimit 是任务查询默认返回条数。
|
||||
defaultTaskQueryLimit = 5
|
||||
// maxTaskQueryLimit 是任务查询允许的最大返回条数,用于限制 LLM 输出范围。
|
||||
maxTaskQueryLimit = 20
|
||||
)
|
||||
|
||||
// ==================== 优先级中文映射 ====================
|
||||
|
||||
// taskQueryPriorityLabelCN 将象限编号转为中文标签。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责 1~4 的合法映射,超出范围返回"未知"。
|
||||
// 2. 不依赖旧链路 agentmodel.PriorityLabelCN,保持新工具自包含。
|
||||
func taskQueryPriorityLabelCN(priority int) string {
|
||||
switch priority {
|
||||
case 1:
|
||||
return "重要且紧急"
|
||||
case 2:
|
||||
return "重要不紧急"
|
||||
case 3:
|
||||
return "简单不重要"
|
||||
case 4:
|
||||
return "复杂不重要"
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
// TaskQueryDeps 描述任务查询工具所需的外部依赖。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. QueryTasks 负责真正查库,工具层不直接依赖 DAO;
|
||||
// 2. UserID 由 execute 节点通过 args["_user_id"] 注入,工具层不自行解析会话身份。
|
||||
type TaskQueryDeps struct {
|
||||
// QueryTasks 将解析后的查询参数传入业务层,返回匹配的任务列表。
|
||||
// 调用目的:解耦工具层与 DAO 层,方便测试和替换。
|
||||
QueryTasks func(ctx context.Context, userID int, params TaskQueryParams) ([]TaskQueryResult, error)
|
||||
}
|
||||
|
||||
// TaskQueryParams 描述任务查询工具传给业务层的内部查询参数。
|
||||
//
|
||||
// 输入输出语义:
|
||||
// 1. 所有筛选条件均为可选,Quadrant 为 nil 表示不限象限。
|
||||
// 2. 时间边界为 nil 表示不限时间范围。
|
||||
type TaskQueryParams struct {
|
||||
Quadrant *int
|
||||
SortBy string // deadline | priority | id
|
||||
Order string // asc | desc
|
||||
Limit int
|
||||
IncludeCompleted bool
|
||||
Keyword string
|
||||
DeadlineBefore *time.Time
|
||||
DeadlineAfter *time.Time
|
||||
}
|
||||
|
||||
// TaskQueryResult 描述任务查询工具返回给 LLM 的轻量任务视图。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只承载展示所需字段,避免暴露底层数据库结构。
|
||||
// 2. JSON 序列化后直接作为工具 observation 返回给 LLM。
|
||||
type TaskQueryResult struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PriorityGroup int `json:"priority_group"`
|
||||
PriorityLabel string `json:"priority_label"`
|
||||
IsCompleted bool `json:"is_completed"`
|
||||
DeadlineAt string `json:"deadline_at,omitempty"`
|
||||
}
|
||||
|
||||
// ==================== 时间解析 ====================
|
||||
|
||||
// taskQueryTimeLayouts 支持的时间格式列表,按优先级尝试解析。
|
||||
var taskQueryTimeLayouts = []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02 15:04",
|
||||
"2006-01-02",
|
||||
}
|
||||
|
||||
// parseTaskQueryBoundaryTime 解析截止时间上下界。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. isUpper=true 时,纯日期补到当天 23:59:59。
|
||||
// 2. isUpper=false 时,纯日期补到当天 00:00:00。
|
||||
// 3. 不支持的格式直接返回错误,由调用方决定是否回退。
|
||||
func parseTaskQueryBoundaryTime(raw string, isUpper bool) (*time.Time, error) {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
loc := time.Local
|
||||
for _, layout := range taskQueryTimeLayouts {
|
||||
var (
|
||||
parsed time.Time
|
||||
err error
|
||||
)
|
||||
if layout == time.RFC3339 {
|
||||
parsed, err = time.Parse(layout, text)
|
||||
if err == nil {
|
||||
parsed = parsed.In(loc)
|
||||
}
|
||||
} else {
|
||||
parsed, err = time.ParseInLocation(layout, text, loc)
|
||||
}
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 1. 纯日期格式需要根据上下界补齐时分秒,保证时间区间语义正确。
|
||||
// 2. 若用户输入"2026-04-20"作为上界,意图是"截止到那天结束",
|
||||
// 所以补 23:59:59;作为下界则补 00:00:00。
|
||||
if layout == "2006-01-02" {
|
||||
if isUpper {
|
||||
parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, 0, loc)
|
||||
} else {
|
||||
parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, loc)
|
||||
}
|
||||
}
|
||||
return &parsed, nil
|
||||
}
|
||||
return nil, fmt.Errorf("时间格式不支持: %s", text)
|
||||
}
|
||||
|
||||
// formatTaskQueryTime 将内部时间格式化为给模型展示的分钟级文本。
|
||||
func formatTaskQueryTime(value *time.Time) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return value.In(time.Local).Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
// ==================== 工具 Handler ====================
|
||||
|
||||
// NewTaskQueryToolHandler 创建 query_tasks 工具的 handler 闭包。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责参数校验、时间解析、调 deps 查库、组装返回;
|
||||
// 2. 不负责 LLM 交互和会话管理。
|
||||
// 3. state 参数忽略——任务查询不需要 ScheduleState,已注册到 scheduleFreeTools。
|
||||
func NewTaskQueryToolHandler(deps TaskQueryDeps) ToolHandler {
|
||||
return func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
_ = state
|
||||
|
||||
// 1. 提取 _user_id(由 execute 节点在调用前注入)。
|
||||
userID := 0
|
||||
if uid, ok := args["_user_id"].(int); ok {
|
||||
userID = uid
|
||||
}
|
||||
if userID <= 0 {
|
||||
return "工具调用失败:无法识别用户身份。"
|
||||
}
|
||||
|
||||
// 2. 提取并校验查询参数。
|
||||
params, err := extractTaskQueryParams(args)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("工具调用失败:%s", err)
|
||||
}
|
||||
|
||||
// 3. 调用依赖查库。
|
||||
results, err := deps.QueryTasks(context.Background(), userID, params)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("工具调用失败:查询任务时出错(%s)。", err)
|
||||
}
|
||||
|
||||
// 4. 为每条结果填充优先级中文标签。
|
||||
for i := range results {
|
||||
results[i].PriorityLabel = taskQueryPriorityLabelCN(results[i].PriorityGroup)
|
||||
}
|
||||
|
||||
// 5. 返回结构化 JSON。
|
||||
if len(results) == 0 {
|
||||
return `{"total":0,"items":[],"message":"当前没有匹配的任务。"}`
|
||||
}
|
||||
|
||||
output := struct {
|
||||
Total int `json:"total"`
|
||||
Items []TaskQueryResult `json:"items"`
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Total: len(results),
|
||||
Items: results,
|
||||
Message: fmt.Sprintf("找到 %d 条匹配任务。", len(results)),
|
||||
}
|
||||
|
||||
jsonBytes, marshalErr := json.Marshal(output)
|
||||
if marshalErr != nil {
|
||||
// JSON 序列化失败时降级为纯文本,确保 LLM 仍能拿到关键信息。
|
||||
return fmt.Sprintf("找到 %d 条匹配任务。", len(results))
|
||||
}
|
||||
return string(jsonBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// extractTaskQueryParams 从 args 提取并校验任务查询参数。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 先准备默认值,保证空参数也能执行一次合理查询。
|
||||
// 2. 再校验象限、排序、条数和时间区间,阻止非法参数下沉到业务层。
|
||||
// 3. 若上下界冲突,则直接返回错误。
|
||||
func extractTaskQueryParams(args map[string]any) (TaskQueryParams, error) {
|
||||
params := TaskQueryParams{
|
||||
SortBy: "deadline",
|
||||
Order: "asc",
|
||||
Limit: defaultTaskQueryLimit,
|
||||
IncludeCompleted: false,
|
||||
}
|
||||
|
||||
// 2.1 象限:1~4,超出范围拒绝。
|
||||
if v, ok := args["quadrant"]; ok {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
q := int(val)
|
||||
if q < 1 || q > 4 {
|
||||
return TaskQueryParams{}, fmt.Errorf("quadrant=%d 非法,必须在 1~4", q)
|
||||
}
|
||||
params.Quadrant = &q
|
||||
case int:
|
||||
if val < 1 || val > 4 {
|
||||
return TaskQueryParams{}, fmt.Errorf("quadrant=%d 非法,必须在 1~4", val)
|
||||
}
|
||||
params.Quadrant = &val
|
||||
}
|
||||
}
|
||||
|
||||
// 2.2 排序字段:仅支持 deadline/priority/id。
|
||||
if v, ok := args["sort_by"].(string); ok {
|
||||
sortBy := strings.ToLower(strings.TrimSpace(v))
|
||||
if sortBy != "" {
|
||||
switch sortBy {
|
||||
case "deadline", "priority", "id":
|
||||
params.SortBy = sortBy
|
||||
default:
|
||||
return TaskQueryParams{}, fmt.Errorf("sort_by=%s 非法,仅支持 deadline|priority|id", sortBy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2.3 排序方向:仅支持 asc/desc。
|
||||
if v, ok := args["order"].(string); ok {
|
||||
order := strings.ToLower(strings.TrimSpace(v))
|
||||
if order != "" {
|
||||
switch order {
|
||||
case "asc", "desc":
|
||||
params.Order = order
|
||||
default:
|
||||
return TaskQueryParams{}, fmt.Errorf("order=%s 非法,仅支持 asc|desc", order)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2.4 条数:默认 5,上限 20。
|
||||
if v, ok := args["limit"]; ok {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
params.Limit = int(val)
|
||||
case int:
|
||||
params.Limit = val
|
||||
}
|
||||
}
|
||||
if params.Limit <= 0 {
|
||||
params.Limit = defaultTaskQueryLimit
|
||||
}
|
||||
if params.Limit > maxTaskQueryLimit {
|
||||
params.Limit = maxTaskQueryLimit
|
||||
}
|
||||
|
||||
// 2.5 是否包含已完成任务。
|
||||
if v, ok := args["include_completed"]; ok {
|
||||
switch val := v.(type) {
|
||||
case bool:
|
||||
params.IncludeCompleted = val
|
||||
}
|
||||
}
|
||||
|
||||
// 2.6 关键词。
|
||||
if v, ok := args["keyword"].(string); ok {
|
||||
params.Keyword = strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
// 2.7 时间边界解析,解析失败直接报错,避免查出无意义的结果。
|
||||
beforeRaw, _ := args["deadline_before"].(string)
|
||||
before, err := parseTaskQueryBoundaryTime(beforeRaw, true)
|
||||
if err != nil {
|
||||
return TaskQueryParams{}, fmt.Errorf("deadline_before 格式错误: %s", err)
|
||||
}
|
||||
params.DeadlineBefore = before
|
||||
|
||||
afterRaw, _ := args["deadline_after"].(string)
|
||||
after, err := parseTaskQueryBoundaryTime(afterRaw, false)
|
||||
if err != nil {
|
||||
return TaskQueryParams{}, fmt.Errorf("deadline_after 格式错误: %s", err)
|
||||
}
|
||||
params.DeadlineAfter = after
|
||||
|
||||
// 2.8 时间区间合法性校验:下界不能晚于上界。
|
||||
if params.DeadlineBefore != nil && params.DeadlineAfter != nil &&
|
||||
params.DeadlineAfter.After(*params.DeadlineBefore) {
|
||||
return TaskQueryParams{}, fmt.Errorf("deadline_after 不能晚于 deadline_before")
|
||||
}
|
||||
|
||||
return params, nil
|
||||
}
|
||||
@@ -54,16 +54,13 @@ var toolProfileByName = map[string]toolProfile{
|
||||
"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},
|
||||
"place": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
|
||||
"move": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
|
||||
"swap": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
|
||||
"batch_move": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
|
||||
"unplace": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
|
||||
|
||||
"analyze_rhythm": {Domain: ToolDomainSchedule, Pack: ToolPackDeepAnalyze},
|
||||
"analyze_tolerance": {Domain: ToolDomainSchedule, Pack: ToolPackDeepAnalyze},
|
||||
"analyze_rhythm": {Domain: ToolDomainSchedule, Pack: ToolPackDeepAnalyze},
|
||||
|
||||
"web_search": {Domain: ToolDomainSchedule, Pack: ToolPackWeb},
|
||||
"web_fetch": {Domain: ToolDomainSchedule, Pack: ToolPackWeb},
|
||||
|
||||
@@ -253,7 +253,7 @@ func (s *AgentService) runNewAgentGraph(
|
||||
|
||||
// 11.6. graph 完成后条件触发记忆抽取。
|
||||
// 说明:
|
||||
// 1. 只有本轮未使用 quick_note_create 时才触发记忆抽取;
|
||||
// 1. 只有本轮未走快捷随口记任务路径时才触发记忆抽取;
|
||||
// 2. 避免随口记创建的 Task 与记忆系统产生语义冲突。
|
||||
if finalState != nil {
|
||||
cs := finalState.EnsureRuntimeState().EnsureCommonState()
|
||||
|
||||
@@ -19,7 +19,15 @@ export interface TimelineConfirmPayload {
|
||||
export interface TimelineEvent {
|
||||
id: number
|
||||
seq: number
|
||||
kind: 'user_text' | 'assistant_text' | 'tool_call' | 'tool_result' | 'confirm_request' | 'schedule_completed'
|
||||
kind:
|
||||
| 'user_text'
|
||||
| 'assistant_text'
|
||||
| 'tool_call'
|
||||
| 'tool_result'
|
||||
| 'confirm_request'
|
||||
| 'schedule_completed'
|
||||
| 'interrupt'
|
||||
| 'status'
|
||||
role?: 'user' | 'assistant'
|
||||
content?: string
|
||||
payload?: {
|
||||
|
||||
@@ -504,6 +504,22 @@ function appendToolTraceEvent(
|
||||
}
|
||||
|
||||
ensureToolTraceBucket(messageId)
|
||||
const normalizedDetail = detail.trim()
|
||||
const normalizedToolName = toolName.trim()
|
||||
const matchedPendingEvent = findMergeableToolTraceEvent(
|
||||
messageId,
|
||||
state,
|
||||
normalizedSummary,
|
||||
normalizedDetail,
|
||||
normalizedToolName,
|
||||
)
|
||||
if (matchedPendingEvent) {
|
||||
matchedPendingEvent.state = state
|
||||
matchedPendingEvent.summary = normalizedSummary
|
||||
matchedPendingEvent.detail = normalizedDetail || matchedPendingEvent.detail
|
||||
matchedPendingEvent.toolName = normalizedToolName || matchedPendingEvent.toolName
|
||||
return
|
||||
}
|
||||
const eventSeq = nextAssistantTimelineSeq()
|
||||
const eventId = `${messageId}:tool:${eventSeq}`
|
||||
|
||||
@@ -517,12 +533,84 @@ function appendToolTraceEvent(
|
||||
seq: eventSeq,
|
||||
state,
|
||||
summary: normalizedSummary,
|
||||
detail: detail.trim() || undefined,
|
||||
toolName: toolName.trim() || undefined,
|
||||
detail: normalizedDetail || undefined,
|
||||
toolName: normalizedToolName || undefined,
|
||||
})
|
||||
assistantTimelineLastKindMap[messageId] = 'tool'
|
||||
}
|
||||
|
||||
function isPendingToolTraceState(state: ToolTraceState) {
|
||||
return state === 'called'
|
||||
}
|
||||
|
||||
function findMergeableToolTraceEvent(
|
||||
messageId: string,
|
||||
nextState: ToolTraceState,
|
||||
summary: string,
|
||||
detail: string,
|
||||
toolName: string,
|
||||
): ToolTraceEvent | null {
|
||||
if (nextState === 'called') {
|
||||
return null
|
||||
}
|
||||
|
||||
const pendingEvents = (toolTraceEventsMap[messageId] || [])
|
||||
.slice()
|
||||
.reverse()
|
||||
.filter((event) => isPendingToolTraceState(event.state))
|
||||
|
||||
if (pendingEvents.length <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedToolName = toolName.trim().toLowerCase()
|
||||
const normalizedDetail = detail.trim()
|
||||
const normalizedSummary = summary.trim()
|
||||
|
||||
if (normalizedToolName && normalizedDetail) {
|
||||
const exactMatch = pendingEvents.find((event) => {
|
||||
return (
|
||||
`${event.toolName || ''}`.trim().toLowerCase() === normalizedToolName &&
|
||||
`${event.detail || ''}`.trim() === normalizedDetail
|
||||
)
|
||||
})
|
||||
if (exactMatch) {
|
||||
return exactMatch
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedToolName) {
|
||||
const toolNameMatch = pendingEvents.find((event) => {
|
||||
return `${event.toolName || ''}`.trim().toLowerCase() === normalizedToolName
|
||||
})
|
||||
if (toolNameMatch) {
|
||||
return toolNameMatch
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedDetail) {
|
||||
const detailMatch = pendingEvents.find((event) => {
|
||||
return `${event.detail || ''}`.trim() === normalizedDetail
|
||||
})
|
||||
if (detailMatch) {
|
||||
return detailMatch
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedSummary) {
|
||||
const summaryMatch = pendingEvents.find((event) => event.summary === normalizedSummary)
|
||||
if (summaryMatch) {
|
||||
return summaryMatch
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingEvents.length === 1) {
|
||||
return pendingEvents[0]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function appendStatusTraceEvent(
|
||||
messageId: string,
|
||||
code: string,
|
||||
@@ -725,9 +813,46 @@ function shouldSkipStatusEvent(code: string, stage = '') {
|
||||
if (stage === 'confirm' && (code === 'plan_confirm' || code === 'tool_confirm' || code === 'confirm')) {
|
||||
return true
|
||||
}
|
||||
|
||||
const hiddenStatusCodes = new Set([
|
||||
'accepted',
|
||||
'ask_user',
|
||||
'planning',
|
||||
'resumed',
|
||||
'confirmed',
|
||||
'rejected',
|
||||
'executing',
|
||||
'summarizing',
|
||||
'done',
|
||||
'rough_building',
|
||||
'order_guard_initialized',
|
||||
'order_guard_passed',
|
||||
'order_guard_restored',
|
||||
'order_guard_restore_skipped',
|
||||
'context_compact_start',
|
||||
'context_compact_done',
|
||||
'plan_auto_confirmed',
|
||||
])
|
||||
|
||||
if (hiddenStatusCodes.has(code)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isAssistantTimelineKind(kind: string) {
|
||||
const assistantKinds = new Set([
|
||||
'assistant_text',
|
||||
'tool_call',
|
||||
'tool_result',
|
||||
'confirm_request',
|
||||
'schedule_completed',
|
||||
'interrupt',
|
||||
'status',
|
||||
])
|
||||
return assistantKinds.has(kind)
|
||||
}
|
||||
|
||||
function isToolTraceExpanded(eventId: string) {
|
||||
return toolTraceExpandedMap[eventId] === true
|
||||
}
|
||||
@@ -1582,12 +1707,12 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[
|
||||
const kind = String(event.kind || '').toLowerCase()
|
||||
const rawRole = String(event.role || '').toLowerCase()
|
||||
|
||||
// 如果 role 已明确为 user,或者 kind 包含 user 关键字
|
||||
// 1. timeline 重建时先识别显式 user 事件,避免把真正的用户输入吞进 assistant 回合。
|
||||
// 2. interrupt / status 这类 assistant 侧协议事件不能再掉进 user 兜底,否则会把 ask_user 正文切断。
|
||||
// 3. 这里仍保留 kind.includes('user') 的保守判断,只是把 assistant 白名单补齐到本轮真实协议。
|
||||
let isUser = rawRole === 'user' || kind.includes('user')
|
||||
// 终极兜底:只要不是明确的五大助手专属事件,就将其视为用户的消息回合边界
|
||||
if (!isUser) {
|
||||
const knownAssistantKinds = ['assistant_text', 'tool_call', 'tool_result', 'confirm_request', 'schedule_completed']
|
||||
if (!knownAssistantKinds.includes(kind)) {
|
||||
if (!isAssistantTimelineKind(kind)) {
|
||||
isUser = true
|
||||
}
|
||||
}
|
||||
@@ -1620,6 +1745,7 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[
|
||||
|
||||
switch (event.kind) {
|
||||
case 'assistant_text':
|
||||
case 'interrupt':
|
||||
if (event.content) {
|
||||
const newContent = event.content
|
||||
const oldContent = currentAssistantMessage.content || ''
|
||||
@@ -1657,14 +1783,14 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[
|
||||
case 'tool_call':
|
||||
if (event.payload?.tool) {
|
||||
const t = event.payload.tool
|
||||
appendToolTraceEvent(mid, mapToolEventState(t.status), t.summary, t.arguments_preview, t.name)
|
||||
appendToolTraceEvent(mid, mapToolEventState(t.status), normalizeToolSummary(t), buildToolDetail(t), t.name)
|
||||
}
|
||||
break
|
||||
|
||||
case 'tool_result':
|
||||
if (event.payload?.tool) {
|
||||
const t = event.payload.tool
|
||||
appendToolTraceEvent(mid, mapToolEventState(t.status), t.summary, t.arguments_preview, t.name)
|
||||
appendToolTraceEvent(mid, mapToolEventState(t.status), normalizeToolSummary(t), buildToolDetail(t), t.name)
|
||||
}
|
||||
break
|
||||
|
||||
|
||||
Reference in New Issue
Block a user