Version: 0.9.75.dev.260505
后端: 1.收口阶段 6 agent 结构迁移,将 newAgent 内核与 agentsvc 编排层迁入 services/agent - 切换 Agent 启动装配与 HTTP handler 直连 agent sv,移除旧 service agent bridge - 补齐 Agent 对 memory、task、task-class、schedule 的 RPC 适配与契约字段 - 扩展 schedule、task、task-class RPC/contract 支撑 Agent 查询、写入与 provider 切流 - 更新迁移文档、README 与相关注释,明确 agent 当前切流点和剩余 memory 迁移面
This commit is contained in:
129
backend/services/agent/conv/schedule_preview.go
Normal file
129
backend/services/agent/conv/schedule_preview.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package agentconv
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
schedule "github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
)
|
||||
|
||||
// ScheduleStateToPreview 将 agent 的 ScheduleState 转换为前端预览缓存格式。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做数据格式转换,不做业务逻辑;
|
||||
// 2. 将每个 ScheduleTask 的每个 TaskSlot 转为一条 HybridScheduleEntry;
|
||||
// 3. Day → (Week, DayOfWeek) 通过 ScheduleState.DayToWeekDay 转换;
|
||||
// 4. 转换失败的 slot(day_index 无效)静默跳过。
|
||||
func ScheduleStateToPreview(
|
||||
state *schedule.ScheduleState,
|
||||
userID int,
|
||||
conversationID string,
|
||||
taskClassIDs []int,
|
||||
summary string,
|
||||
) *model.SchedulePlanPreviewCache {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
entries := make([]model.HybridScheduleEntry, 0, len(state.Tasks))
|
||||
for i := range state.Tasks {
|
||||
t := &state.Tasks[i]
|
||||
// 待安排且无位置的任务不生成 entry。
|
||||
if schedule.IsPendingTask(*t) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, slot := range t.Slots {
|
||||
week, dayOfWeek, ok := state.DayToWeekDay(slot.Day)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
entry := model.HybridScheduleEntry{
|
||||
Week: week,
|
||||
DayOfWeek: dayOfWeek,
|
||||
SectionFrom: slot.SlotStart,
|
||||
SectionTo: slot.SlotEnd,
|
||||
Name: t.Name,
|
||||
}
|
||||
|
||||
// Type 映射。
|
||||
if t.Source == "event" {
|
||||
if t.EventType != "" {
|
||||
entry.Type = t.EventType
|
||||
} else {
|
||||
entry.Type = "course"
|
||||
}
|
||||
} else {
|
||||
entry.Type = "task"
|
||||
}
|
||||
|
||||
// Status 映射:existing 不变,suggested / 兼容建议态统一输出为 suggested。
|
||||
if shouldMarkSuggestedInPreview(*t) {
|
||||
entry.Status = "suggested"
|
||||
} else {
|
||||
entry.Status = "existing"
|
||||
}
|
||||
|
||||
// ID 映射。
|
||||
if t.Source == "event" {
|
||||
entry.EventID = t.SourceID
|
||||
} else {
|
||||
entry.TaskItemID = t.SourceID
|
||||
entry.TaskClassID = t.TaskClassID
|
||||
// 嵌入任务:将宿主课程的 source_id(即 event_id)桥接到 EventID,
|
||||
// 供前端作为 embed_course_event_id 传递给 BatchApplyPlans 做冲突豁免。
|
||||
if t.EmbedHost != nil {
|
||||
if host := state.TaskByStateID(*t.EmbedHost); host != nil {
|
||||
entry.EventID = host.SourceID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 嵌入与阻塞语义。
|
||||
entry.CanBeEmbedded = t.CanEmbed
|
||||
if t.Source == "event" && t.CanEmbed && t.EmbeddedBy == nil {
|
||||
// 可嵌入且当前无嵌入任务 → 不阻塞 suggested 占位。
|
||||
entry.BlockForSuggested = false
|
||||
} else {
|
||||
entry.BlockForSuggested = true
|
||||
}
|
||||
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成摘要(若调用方未提供)。
|
||||
if summary == "" {
|
||||
existingCount := 0
|
||||
suggestedCount := 0
|
||||
for _, e := range entries {
|
||||
if e.Status == "existing" {
|
||||
existingCount++
|
||||
} else {
|
||||
suggestedCount++
|
||||
}
|
||||
}
|
||||
summary = fmt.Sprintf("共 %d 个日程条目,其中已确定 %d 个,新安排 %d 个。", len(entries), existingCount, suggestedCount)
|
||||
}
|
||||
|
||||
return &model.SchedulePlanPreviewCache{
|
||||
UserID: userID,
|
||||
ConversationID: conversationID,
|
||||
Summary: summary,
|
||||
HybridEntries: entries,
|
||||
TaskClassIDs: taskClassIDs,
|
||||
GeneratedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// shouldMarkSuggestedInPreview 判断某条 ScheduleTask 在预览层是否应标记为 suggested。
|
||||
//
|
||||
// 规则说明:
|
||||
// 1. 新语义下,显式 suggested 直接输出为建议态;
|
||||
// 2. 兼容旧快照:pending+Slots、existing+Duration>0 的 task_item 也继续按 suggested 输出;
|
||||
// 3. 这样前端预览口径可以在迁移期保持稳定,不会因为状态枚举切换而抖动。
|
||||
func shouldMarkSuggestedInPreview(t schedule.ScheduleTask) bool {
|
||||
return schedule.IsSuggestedTask(t)
|
||||
}
|
||||
353
backend/services/agent/conv/schedule_provider.go
Normal file
353
backend/services/agent/conv/schedule_provider.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package agentconv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
baseconv "github.com/LoveLosita/smartflow/backend/conv"
|
||||
"github.com/LoveLosita/smartflow/backend/dao"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
schedule "github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
)
|
||||
|
||||
// ScheduleProvider 实现 model.ScheduleStateProvider 接口。
|
||||
// 通过 DAO 层加载用户的日程和任务数据,调用 LoadScheduleState 构建内存状态。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责"从 DB 查数据 + 调 LoadScheduleState 转换",不含业务逻辑;
|
||||
// 2. 不负责缓存(由上层 Service 决定是否缓存);
|
||||
// 3. 不负责 Diff 和持久化(由 Confirm 流程负责)。
|
||||
type ScheduleProvider struct {
|
||||
scheduleDAO *dao.ScheduleDAO
|
||||
taskClassDAO *dao.TaskClassDAO
|
||||
}
|
||||
|
||||
// NewScheduleProvider 创建 ScheduleProvider。
|
||||
func NewScheduleProvider(scheduleDAO *dao.ScheduleDAO, taskClassDAO *dao.TaskClassDAO) *ScheduleProvider {
|
||||
return &ScheduleProvider{
|
||||
scheduleDAO: scheduleDAO,
|
||||
taskClassDAO: taskClassDAO,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadScheduleState 实现 model.ScheduleStateProvider 接口。
|
||||
//
|
||||
// 窗口策略:
|
||||
// 1. 优先从 task class 的 StartDate/EndDate 推算规划窗口,覆盖粗排所需的完整日期范围;
|
||||
// 2. task class 无日期信息时,降级到当前周 7 天(兼容普通查询场景)。
|
||||
//
|
||||
// 日程加载策略:对窗口内每周分别调用 GetUserWeeklySchedule 并合并结果。
|
||||
func (p *ScheduleProvider) LoadScheduleState(ctx context.Context, userID int) (*schedule.ScheduleState, error) {
|
||||
// 1. 加载用户所有任务类(含 Items 预加载)。
|
||||
taskClasses, err := p.loadCompleteTaskClasses(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. 全量读场景保留“当前周兜底”,兼容“只看本周课表/微调”类请求。
|
||||
return p.loadScheduleStateWithTaskClasses(ctx, userID, taskClasses, true)
|
||||
}
|
||||
|
||||
// LoadScheduleStateForTaskClasses 按“本轮请求的任务类范围”加载 ScheduleState。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 负责:让粗排 / Execute 首次读取的 DayMapping 与本轮 task_class_ids 保持同一时间窗口;
|
||||
// 2. 不负责:裁掉窗口内已有的 existing/suggested 阻塞物,这部分仍由日程加载主流程统一保留;
|
||||
// 3. 失败策略:若 task_class_ids 为空,则退回全量加载,避免调用方额外分支。
|
||||
func (p *ScheduleProvider) LoadScheduleStateForTaskClasses(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
taskClassIDs []int,
|
||||
) (*schedule.ScheduleState, error) {
|
||||
if len(taskClassIDs) == 0 {
|
||||
return p.LoadScheduleState(ctx, userID)
|
||||
}
|
||||
|
||||
taskClasses, err := p.loadCompleteTaskClassesByIDs(ctx, userID, taskClassIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 1. 粗排/主动编排场景必须严格按任务类时间窗加载;
|
||||
// 2. 若任务类缺少起止日期,则返回错误,交给上层 ask_user 补齐,而不是静默退回当前周。
|
||||
return p.loadScheduleStateWithTaskClasses(ctx, userID, taskClasses, false)
|
||||
}
|
||||
|
||||
// loadScheduleStateWithTaskClasses 负责把“指定任务类集合”装配成可操作的 ScheduleState。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 先根据传入 taskClasses 计算 DayMapping 窗口,保证粗排坐标能映射回 day_index;
|
||||
// 2. 若窗口无法从任务类日期推导,则退回当前周 7 天,兼容普通查询场景;
|
||||
// 3. 再按窗口覆盖的周批量拉取 existing schedules,与 taskClasses 一起交给 LoadScheduleState 统一建模。
|
||||
func (p *ScheduleProvider) loadScheduleStateWithTaskClasses(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
taskClasses []model.TaskClass,
|
||||
allowCurrentWeekFallback bool,
|
||||
) (*schedule.ScheduleState, error) {
|
||||
// 1. 确定规划窗口:优先使用 task class 日期范围,降级到当前周。
|
||||
windowDays, weeks := buildWindowFromTaskClasses(taskClasses)
|
||||
if len(windowDays) == 0 {
|
||||
if !allowCurrentWeekFallback {
|
||||
return nil, fmt.Errorf("任务类缺少有效时间窗:请补充 start_date/end_date 后再进行智能编排")
|
||||
}
|
||||
var err error
|
||||
windowDays, weeks, err = buildCurrentWeekWindow()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 按周加载日程(含 Event + EmbeddedTask 预加载)。
|
||||
var allSchedules []model.Schedule
|
||||
for _, w := range weeks {
|
||||
weekSchedules, err := p.scheduleDAO.GetUserWeeklySchedule(ctx, userID, w)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载用户周日程失败 week=%d: %w", w, err)
|
||||
}
|
||||
allSchedules = append(allSchedules, weekSchedules...)
|
||||
}
|
||||
|
||||
// 3. 构建额外 item category 映射。
|
||||
extraItemCategories := buildExtraItemCategories(allSchedules, taskClasses)
|
||||
|
||||
// 4. 调用已有的 LoadScheduleState 构建内存状态。
|
||||
return LoadScheduleState(allSchedules, taskClasses, extraItemCategories, windowDays), nil
|
||||
}
|
||||
|
||||
// buildWindowFromTaskClasses 从 task class 的 StartDate/EndDate 推算规划窗口。
|
||||
//
|
||||
// 返回值:
|
||||
// - windowDays:窗口内每天的 (week, dayOfWeek) 有序列表;
|
||||
// - weeks:窗口覆盖的周号(去重、升序),供按周加载日程使用;
|
||||
// - 若无有效日期信息,返回空切片,调用方应降级到默认窗口。
|
||||
func buildWindowFromTaskClasses(taskClasses []model.TaskClass) (windowDays []WindowDay, weeks []int) {
|
||||
minWeek, minDay := 0, 0
|
||||
maxWeek, maxDay := 0, 0
|
||||
hasWindow := false
|
||||
|
||||
for _, tc := range taskClasses {
|
||||
// 1. 先要求任务类具备完整且合法的起止日期,避免坏数据把整轮窗口拖坏。
|
||||
// 2. 再逐条做绝对日期 -> 相对周/天转换;转换失败的任务类直接忽略,不影响其余合法任务类。
|
||||
// 3. 只有至少一条任务类成功进入窗口后,才返回有效 DayMapping。
|
||||
if tc.StartDate == nil || tc.EndDate == nil || tc.EndDate.Before(*tc.StartDate) {
|
||||
continue
|
||||
}
|
||||
startWeek, startDay, err := baseconv.RealDateToRelativeDate(tc.StartDate.Format(baseconv.DateFormat))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
endWeek, endDay, err := baseconv.RealDateToRelativeDate(tc.EndDate.Format(baseconv.DateFormat))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !hasWindow || isRelativeDateBefore(startWeek, startDay, minWeek, minDay) {
|
||||
minWeek, minDay = startWeek, startDay
|
||||
}
|
||||
if !hasWindow || isRelativeDateBefore(maxWeek, maxDay, endWeek, endDay) {
|
||||
maxWeek, maxDay = endWeek, endDay
|
||||
}
|
||||
hasWindow = true
|
||||
}
|
||||
if !hasWindow {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
weeksSet := make(map[int]bool)
|
||||
w, d := minWeek, minDay
|
||||
for {
|
||||
windowDays = append(windowDays, WindowDay{Week: w, DayOfWeek: d})
|
||||
weeksSet[w] = true
|
||||
if w == maxWeek && d == maxDay {
|
||||
break
|
||||
}
|
||||
d++
|
||||
if d > 7 {
|
||||
d = 1
|
||||
w++
|
||||
}
|
||||
if w > maxWeek+1 { // 防止因日期转换异常导致无限循环
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
weeks = make([]int, 0, len(weeksSet))
|
||||
for wk := range weeksSet {
|
||||
weeks = append(weeks, wk)
|
||||
}
|
||||
sort.Ints(weeks)
|
||||
return windowDays, weeks
|
||||
}
|
||||
|
||||
// BuildWindowFromTaskClasses 暴露任务类时间窗计算给 RPC provider 复用。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只复用老 DAO provider 的窗口推导算法,保证迁移前后 day_mapping 口径一致;
|
||||
// 2. 不读取数据库、不调用 RPC;
|
||||
// 3. 无有效日期时返回空切片,由调用方决定是否降级当前周。
|
||||
func BuildWindowFromTaskClasses(taskClasses []model.TaskClass) (windowDays []WindowDay, weeks []int) {
|
||||
return buildWindowFromTaskClasses(taskClasses)
|
||||
}
|
||||
|
||||
// buildCurrentWeekWindow 构造“当前周 7 天”的兜底窗口。
|
||||
func buildCurrentWeekWindow() (windowDays []WindowDay, weeks []int, err error) {
|
||||
now := time.Now()
|
||||
currentWeek, _, err := baseconv.RealDateToRelativeDate(now.Format(baseconv.DateFormat))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("解析当前日期失败: %w", err)
|
||||
}
|
||||
windowDays = make([]WindowDay, 7)
|
||||
for i := 0; i < 7; i++ {
|
||||
windowDays[i] = WindowDay{Week: currentWeek, DayOfWeek: i + 1}
|
||||
}
|
||||
return windowDays, []int{currentWeek}, nil
|
||||
}
|
||||
|
||||
// BuildCurrentWeekWindow 暴露当前周兜底窗口给 RPC provider 复用。
|
||||
func BuildCurrentWeekWindow() (windowDays []WindowDay, weeks []int, err error) {
|
||||
return buildCurrentWeekWindow()
|
||||
}
|
||||
|
||||
// isRelativeDateBefore 比较两个“相对周/天”坐标的先后关系。
|
||||
func isRelativeDateBefore(leftWeek, leftDay, rightWeek, rightDay int) bool {
|
||||
if leftWeek != rightWeek {
|
||||
return leftWeek < rightWeek
|
||||
}
|
||||
return leftDay < rightDay
|
||||
}
|
||||
|
||||
// loadCompleteTaskClasses 批量加载用户所有任务类(含 Items 预加载)。
|
||||
func (p *ScheduleProvider) loadCompleteTaskClasses(ctx context.Context, userID int) ([]model.TaskClass, error) {
|
||||
basicClasses, err := p.taskClassDAO.GetUserTaskClasses(userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载用户任务类失败: %w", err)
|
||||
}
|
||||
if len(basicClasses) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ids := make([]int, len(basicClasses))
|
||||
for i, tc := range basicClasses {
|
||||
ids[i] = tc.ID
|
||||
}
|
||||
|
||||
complete, err := p.taskClassDAO.GetCompleteTaskClassesByIDs(ctx, userID, ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载完整任务类失败: %w", err)
|
||||
}
|
||||
return complete, nil
|
||||
}
|
||||
|
||||
// loadCompleteTaskClassesByIDs 批量加载指定任务类(含 Items 预加载)。
|
||||
func (p *ScheduleProvider) loadCompleteTaskClassesByIDs(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
taskClassIDs []int,
|
||||
) ([]model.TaskClass, error) {
|
||||
if len(taskClassIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
complete, err := p.taskClassDAO.GetCompleteTaskClassesByIDs(ctx, userID, taskClassIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载指定任务类失败: %w", err)
|
||||
}
|
||||
return complete, nil
|
||||
}
|
||||
|
||||
// LoadTaskClassMetas 加载指定任务类的约束元数据(不含 Items、不含日程),供 Plan 阶段提前消费。
|
||||
func (p *ScheduleProvider) LoadTaskClassMetas(ctx context.Context, userID int, taskClassIDs []int) ([]schedule.TaskClassMeta, error) {
|
||||
if len(taskClassIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
complete, err := p.taskClassDAO.GetCompleteTaskClassesByIDs(ctx, userID, taskClassIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载任务类元数据失败: %w", err)
|
||||
}
|
||||
return TaskClassesToScheduleMetas(complete), nil
|
||||
}
|
||||
|
||||
func derefString(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// buildExtraItemCategories 从已有日程中提取不属于给定 taskClasses 的 task event 的 category 映射。
|
||||
// 当加载全部 taskClass 时,通常返回空 map。
|
||||
func buildExtraItemCategories(schedules []model.Schedule, taskClasses []model.TaskClass) map[int]string {
|
||||
knownItemIDs := make(map[int]bool)
|
||||
for _, tc := range taskClasses {
|
||||
for _, item := range tc.Items {
|
||||
knownItemIDs[item.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
categories := make(map[int]string)
|
||||
for _, s := range schedules {
|
||||
if s.Event == nil || s.Event.Type != "task" || s.Event.RelID == nil {
|
||||
continue
|
||||
}
|
||||
itemID := *s.Event.RelID
|
||||
if !knownItemIDs[itemID] {
|
||||
categories[itemID] = "任务"
|
||||
}
|
||||
}
|
||||
return categories
|
||||
}
|
||||
|
||||
// BuildExtraItemCategories 暴露额外任务分类兜底映射给 RPC provider 复用。
|
||||
func BuildExtraItemCategories(schedules []model.Schedule, taskClasses []model.TaskClass) map[int]string {
|
||||
return buildExtraItemCategories(schedules, taskClasses)
|
||||
}
|
||||
|
||||
// TaskClassesToScheduleMetas 把完整任务类转换成工具层约束元数据。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做字段映射,不筛选 pending item;
|
||||
// 2. DAO provider 与 RPC provider 共用,避免迁移后 Plan 阶段元数据口径分裂;
|
||||
// 3. nil 指针字段按工具层零值处理。
|
||||
func TaskClassesToScheduleMetas(taskClasses []model.TaskClass) []schedule.TaskClassMeta {
|
||||
metas := make([]schedule.TaskClassMeta, 0, len(taskClasses))
|
||||
for _, tc := range taskClasses {
|
||||
meta := schedule.TaskClassMeta{
|
||||
ID: tc.ID,
|
||||
Name: derefString(tc.Name),
|
||||
}
|
||||
if tc.Strategy != nil {
|
||||
meta.Strategy = *tc.Strategy
|
||||
}
|
||||
if tc.TotalSlots != nil {
|
||||
meta.TotalSlots = *tc.TotalSlots
|
||||
}
|
||||
if tc.AllowFillerCourse != nil {
|
||||
meta.AllowFillerCourse = *tc.AllowFillerCourse
|
||||
}
|
||||
if tc.ExcludedSlots != nil {
|
||||
meta.ExcludedSlots = []int(tc.ExcludedSlots)
|
||||
}
|
||||
if tc.ExcludedDaysOfWeek != nil {
|
||||
meta.ExcludedDaysOfWeek = []int(tc.ExcludedDaysOfWeek)
|
||||
}
|
||||
if tc.StartDate != nil {
|
||||
meta.StartDate = tc.StartDate.Format("2006-01-02")
|
||||
}
|
||||
if tc.EndDate != nil {
|
||||
meta.EndDate = tc.EndDate.Format("2006-01-02")
|
||||
}
|
||||
if tc.SubjectType != nil {
|
||||
meta.SubjectType = *tc.SubjectType
|
||||
}
|
||||
if tc.DifficultyLevel != nil {
|
||||
meta.DifficultyLevel = *tc.DifficultyLevel
|
||||
}
|
||||
if tc.CognitiveIntensity != nil {
|
||||
meta.CognitiveIntensity = *tc.CognitiveIntensity
|
||||
}
|
||||
metas = append(metas, meta)
|
||||
}
|
||||
return metas
|
||||
}
|
||||
631
backend/services/agent/conv/schedule_state.go
Normal file
631
backend/services/agent/conv/schedule_state.go
Normal file
@@ -0,0 +1,631 @@
|
||||
package agentconv
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
schedule "github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
)
|
||||
|
||||
// WindowDay 表示排课窗口中的一天(相对周 + 周几)。
|
||||
type WindowDay struct {
|
||||
Week int
|
||||
DayOfWeek int
|
||||
}
|
||||
|
||||
// LoadScheduleState 将数据库层的 schedules + taskClasses 聚合为 agent 工具层可直接操作的 ScheduleState。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责数据映射与状态归一,不做数据库读写;
|
||||
// 2. 同时兼容三种“任务已落位”信号:event.rel_id、schedules.embedded_task_id、task_item.embedded_time;
|
||||
// 3. 对嵌入课程任务优先判定为 existing,避免误挂回 pending。
|
||||
func LoadScheduleState(
|
||||
schedules []model.Schedule,
|
||||
taskClasses []model.TaskClass,
|
||||
extraItemCategories map[int]string,
|
||||
windowDays []WindowDay,
|
||||
) *schedule.ScheduleState {
|
||||
state := &schedule.ScheduleState{
|
||||
Window: schedule.ScheduleWindow{
|
||||
TotalDays: len(windowDays),
|
||||
DayMapping: make([]schedule.DayMapping, len(windowDays)),
|
||||
},
|
||||
Tasks: make([]schedule.ScheduleTask, 0),
|
||||
}
|
||||
|
||||
// 1. 构建 day_index 与 (week, day_of_week) 的双向转换基础索引。
|
||||
dayLookup := make(map[[2]int]int, len(windowDays))
|
||||
for i, wd := range windowDays {
|
||||
dayIndex := i + 1
|
||||
state.Window.DayMapping[i] = schedule.DayMapping{
|
||||
DayIndex: dayIndex,
|
||||
Week: wd.Week,
|
||||
DayOfWeek: wd.DayOfWeek,
|
||||
}
|
||||
dayLookup[[2]int{wd.Week, wd.DayOfWeek}] = dayIndex
|
||||
}
|
||||
|
||||
// 2. 构建 task_item -> 分类名映射。
|
||||
// 2.1 先放 extraItemCategories(低优先级,兜底);
|
||||
// 2.2 再用 taskClasses 覆盖(高优先级,确保本轮排课分类准确)。
|
||||
itemCategoryLookup := make(map[int]string)
|
||||
itemOrderLookup := buildTaskItemOrderLookup(taskClasses)
|
||||
for id, name := range extraItemCategories {
|
||||
itemCategoryLookup[id] = name
|
||||
}
|
||||
for _, tc := range taskClasses {
|
||||
catName := "任务"
|
||||
if tc.Name != nil && *tc.Name != "" {
|
||||
catName = *tc.Name
|
||||
}
|
||||
for _, item := range tc.Items {
|
||||
itemCategoryLookup[item.ID] = catName
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 先把 schedules 聚合成 event 任务(existing)。
|
||||
type slotGroup struct {
|
||||
week int
|
||||
dayOfWeek int
|
||||
sections []int
|
||||
}
|
||||
eventSlotMap := make(map[int][]slotGroup) // eventID -> 多天多段槽位
|
||||
eventInfo := make(map[int]*model.ScheduleEvent)
|
||||
|
||||
for i := range schedules {
|
||||
s := &schedules[i]
|
||||
if s.Event == nil {
|
||||
continue
|
||||
}
|
||||
if _, exists := eventInfo[s.EventID]; !exists {
|
||||
eventInfo[s.EventID] = s.Event
|
||||
}
|
||||
|
||||
groups := eventSlotMap[s.EventID]
|
||||
found := false
|
||||
for gi := range groups {
|
||||
if groups[gi].week == s.Week && groups[gi].dayOfWeek == s.DayOfWeek {
|
||||
groups[gi].sections = append(groups[gi].sections, s.Section)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
groups = append(groups, slotGroup{
|
||||
week: s.Week,
|
||||
dayOfWeek: s.DayOfWeek,
|
||||
sections: []int{s.Section},
|
||||
})
|
||||
}
|
||||
eventSlotMap[s.EventID] = groups
|
||||
}
|
||||
|
||||
nextStateID := 1
|
||||
eventStateIDs := make(map[int]int) // eventID -> stateID
|
||||
for eventID, groups := range eventSlotMap {
|
||||
event := eventInfo[eventID]
|
||||
if event == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
category := "课程"
|
||||
if event.Type == "task" {
|
||||
category = "任务"
|
||||
if event.RelID != nil {
|
||||
if cat, ok := itemCategoryLookup[*event.RelID]; ok && cat != "" {
|
||||
category = cat
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
locked := event.Type == "course" && !event.CanBeEmbedded
|
||||
var slots []schedule.TaskSlot
|
||||
for _, g := range groups {
|
||||
if len(g.sections) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Ints(g.sections)
|
||||
start, end := g.sections[0], g.sections[0]
|
||||
for _, sec := range g.sections[1:] {
|
||||
if sec == end+1 {
|
||||
end = sec
|
||||
continue
|
||||
}
|
||||
if day, ok := dayLookup[[2]int{g.week, g.dayOfWeek}]; ok {
|
||||
slots = append(slots, schedule.TaskSlot{Day: day, SlotStart: start, SlotEnd: end})
|
||||
}
|
||||
start, end = sec, sec
|
||||
}
|
||||
if day, ok := dayLookup[[2]int{g.week, g.dayOfWeek}]; ok {
|
||||
slots = append(slots, schedule.TaskSlot{Day: day, SlotStart: start, SlotEnd: end})
|
||||
}
|
||||
}
|
||||
sort.Slice(slots, func(i, j int) bool {
|
||||
if slots[i].Day != slots[j].Day {
|
||||
return slots[i].Day < slots[j].Day
|
||||
}
|
||||
return slots[i].SlotStart < slots[j].SlotStart
|
||||
})
|
||||
|
||||
stateID := nextStateID
|
||||
state.Tasks = append(state.Tasks, schedule.ScheduleTask{
|
||||
StateID: stateID,
|
||||
Source: "event",
|
||||
SourceID: eventID,
|
||||
Name: event.Name,
|
||||
Category: category,
|
||||
Status: "existing",
|
||||
Locked: locked,
|
||||
Slots: slots,
|
||||
CanEmbed: event.CanBeEmbedded,
|
||||
EventType: event.Type,
|
||||
})
|
||||
eventStateIDs[eventID] = stateID
|
||||
nextStateID++
|
||||
}
|
||||
|
||||
// 4. 构建 task_item 占位索引(后续 pending 判定优先用这两个索引短路)。
|
||||
// 4.1 event.rel_id 占位:该 item 已有 task event;
|
||||
// 4.2 schedules.embedded_task_id 占位:该 item 已嵌入到课程槽位。
|
||||
itemIDToTaskEventStateID := make(map[int]int)
|
||||
for eventID, stateID := range eventStateIDs {
|
||||
event := eventInfo[eventID]
|
||||
if event == nil || event.Type != "task" || event.RelID == nil {
|
||||
continue
|
||||
}
|
||||
itemIDToTaskEventStateID[*event.RelID] = stateID
|
||||
}
|
||||
|
||||
itemIDToEmbedHostStateID := make(map[int]int)
|
||||
for i := range schedules {
|
||||
s := &schedules[i]
|
||||
if s.EmbeddedTaskID == nil {
|
||||
continue
|
||||
}
|
||||
hostStateID, ok := eventStateIDs[s.EventID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
itemIDToEmbedHostStateID[*s.EmbeddedTaskID] = hostStateID
|
||||
}
|
||||
|
||||
// 5. 处理 task_items:
|
||||
// 5.1 先消化 existing(task event / 课程嵌入 / embedded_time);
|
||||
// 5.2 剩余条目再按 status 判 pending。
|
||||
itemStateIDs := make(map[int]int) // task_item_id -> stateID
|
||||
for _, tc := range taskClasses {
|
||||
catName := "任务"
|
||||
if tc.Name != nil && *tc.Name != "" {
|
||||
catName = *tc.Name
|
||||
}
|
||||
defaultDuration := estimateTaskItemDuration(tc)
|
||||
pendingCount := 0
|
||||
|
||||
for _, item := range tc.Items {
|
||||
if stateID, ok := itemIDToTaskEventStateID[item.ID]; ok {
|
||||
itemStateIDs[item.ID] = stateID
|
||||
continue
|
||||
}
|
||||
|
||||
if hostStateID, ok := itemIDToEmbedHostStateID[item.ID]; ok {
|
||||
hostSlots := []schedule.TaskSlot(nil)
|
||||
if hostTask := state.TaskByStateID(hostStateID); hostTask != nil {
|
||||
hostSlots = cloneTaskSlots(hostTask.Slots)
|
||||
}
|
||||
stateID := nextStateID
|
||||
state.Tasks = append(state.Tasks, schedule.ScheduleTask{
|
||||
StateID: stateID,
|
||||
Source: "task_item",
|
||||
SourceID: item.ID,
|
||||
Name: taskItemName(item),
|
||||
Category: catName,
|
||||
Status: "existing",
|
||||
Slots: hostSlots,
|
||||
CategoryID: tc.ID,
|
||||
TaskClassID: tc.ID,
|
||||
TaskOrder: itemOrderLookup[item.ID],
|
||||
})
|
||||
itemStateIDs[item.ID] = stateID
|
||||
nextStateID++
|
||||
continue
|
||||
}
|
||||
|
||||
if slots, ok := slotsFromTargetTime(item.EmbeddedTime, dayLookup); ok {
|
||||
stateID := nextStateID
|
||||
state.Tasks = append(state.Tasks, schedule.ScheduleTask{
|
||||
StateID: stateID,
|
||||
Source: "task_item",
|
||||
SourceID: item.ID,
|
||||
Name: taskItemName(item),
|
||||
Category: catName,
|
||||
Status: "existing",
|
||||
Slots: slots,
|
||||
CategoryID: tc.ID,
|
||||
TaskClassID: tc.ID,
|
||||
TaskOrder: itemOrderLookup[item.ID],
|
||||
})
|
||||
itemStateIDs[item.ID] = stateID
|
||||
nextStateID++
|
||||
continue
|
||||
}
|
||||
|
||||
if !isTaskItemPending(item) {
|
||||
continue
|
||||
}
|
||||
|
||||
stateID := nextStateID
|
||||
state.Tasks = append(state.Tasks, schedule.ScheduleTask{
|
||||
StateID: stateID,
|
||||
Source: "task_item",
|
||||
SourceID: item.ID,
|
||||
Name: taskItemName(item),
|
||||
Category: catName,
|
||||
Status: "pending",
|
||||
Duration: defaultDuration,
|
||||
CategoryID: tc.ID,
|
||||
TaskClassID: tc.ID,
|
||||
TaskOrder: itemOrderLookup[item.ID],
|
||||
})
|
||||
itemStateIDs[item.ID] = stateID
|
||||
nextStateID++
|
||||
pendingCount++
|
||||
}
|
||||
|
||||
// 仅当该任务类仍有 pending item 时,才把约束暴露给 LLM。
|
||||
if pendingCount > 0 {
|
||||
meta := schedule.TaskClassMeta{
|
||||
ID: tc.ID,
|
||||
Name: catName,
|
||||
}
|
||||
if tc.Strategy != nil {
|
||||
meta.Strategy = *tc.Strategy
|
||||
}
|
||||
if tc.TotalSlots != nil {
|
||||
meta.TotalSlots = *tc.TotalSlots
|
||||
}
|
||||
if tc.AllowFillerCourse != nil {
|
||||
meta.AllowFillerCourse = *tc.AllowFillerCourse
|
||||
}
|
||||
if tc.ExcludedSlots != nil {
|
||||
meta.ExcludedSlots = []int(tc.ExcludedSlots)
|
||||
}
|
||||
if tc.ExcludedDaysOfWeek != nil {
|
||||
meta.ExcludedDaysOfWeek = []int(tc.ExcludedDaysOfWeek)
|
||||
}
|
||||
if tc.StartDate != nil {
|
||||
meta.StartDate = tc.StartDate.Format("2006-01-02")
|
||||
}
|
||||
if tc.EndDate != nil {
|
||||
meta.EndDate = tc.EndDate.Format("2006-01-02")
|
||||
}
|
||||
if tc.SubjectType != nil {
|
||||
meta.SubjectType = *tc.SubjectType
|
||||
}
|
||||
if tc.DifficultyLevel != nil {
|
||||
meta.DifficultyLevel = *tc.DifficultyLevel
|
||||
}
|
||||
if tc.CognitiveIntensity != nil {
|
||||
meta.CognitiveIntensity = *tc.CognitiveIntensity
|
||||
}
|
||||
state.TaskClasses = append(state.TaskClasses, meta)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 统一回填嵌入关系:
|
||||
// 6.1 host 记录 EmbeddedBy;
|
||||
// 6.2 guest 记录 EmbedHost;
|
||||
// 6.3 guest 强制 existing + host slots,防止“嵌入任务残留 pending”。
|
||||
for i := range schedules {
|
||||
s := &schedules[i]
|
||||
if s.EmbeddedTaskID == nil {
|
||||
continue
|
||||
}
|
||||
hostStateID, ok := eventStateIDs[s.EventID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
hostTask := state.TaskByStateID(hostStateID)
|
||||
itemID := *s.EmbeddedTaskID
|
||||
|
||||
guestStateID, ok := itemStateIDs[itemID]
|
||||
if !ok {
|
||||
// 兜底:只在 schedules 层看到嵌入关系,taskClasses 不含该 item 时补建 guest。
|
||||
name := ""
|
||||
categoryID := 0
|
||||
taskClassID := 0
|
||||
if s.EmbeddedTask != nil {
|
||||
name = taskItemName(*s.EmbeddedTask)
|
||||
if s.EmbeddedTask.CategoryID != nil {
|
||||
categoryID = *s.EmbeddedTask.CategoryID
|
||||
taskClassID = *s.EmbeddedTask.CategoryID
|
||||
}
|
||||
}
|
||||
category := "任务"
|
||||
if cat, exists := itemCategoryLookup[itemID]; exists && cat != "" {
|
||||
category = cat
|
||||
}
|
||||
hostSlots := []schedule.TaskSlot(nil)
|
||||
if hostTask != nil {
|
||||
hostSlots = cloneTaskSlots(hostTask.Slots)
|
||||
}
|
||||
guestStateID = nextStateID
|
||||
state.Tasks = append(state.Tasks, schedule.ScheduleTask{
|
||||
StateID: guestStateID,
|
||||
Source: "task_item",
|
||||
SourceID: itemID,
|
||||
Name: name,
|
||||
Category: category,
|
||||
Status: "existing",
|
||||
Slots: hostSlots,
|
||||
CategoryID: categoryID,
|
||||
TaskClassID: taskClassID,
|
||||
TaskOrder: itemOrderLookup[itemID],
|
||||
})
|
||||
itemStateIDs[itemID] = guestStateID
|
||||
nextStateID++
|
||||
}
|
||||
|
||||
if hostTask != nil && hostTask.EmbeddedBy == nil {
|
||||
v := guestStateID
|
||||
hostTask.EmbeddedBy = &v
|
||||
}
|
||||
|
||||
guestTask := state.TaskByStateID(guestStateID)
|
||||
if guestTask == nil {
|
||||
continue
|
||||
}
|
||||
if guestTask.EmbedHost == nil {
|
||||
v := hostStateID
|
||||
guestTask.EmbedHost = &v
|
||||
}
|
||||
guestTask.Status = "existing"
|
||||
if hostTask != nil && len(guestTask.Slots) == 0 {
|
||||
guestTask.Slots = cloneTaskSlots(hostTask.Slots)
|
||||
}
|
||||
// existing 的 task_item 不应再携带 Duration,避免预览层误判成 suggested。
|
||||
guestTask.Duration = 0
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
// isTaskItemPending 仅根据 status 判断是否应进入 pending 池。
|
||||
//
|
||||
// 说明:
|
||||
// 1. status=nil 兼容历史数据,按“未安排”处理;
|
||||
// 2. 仅 status=TaskItemStatusUnscheduled 进入 pending;
|
||||
// 3. 其它“已安排”信号由 LoadScheduleState 主流程统一判定,避免多处口径不一致。
|
||||
func isTaskItemPending(item model.TaskClassItem) bool {
|
||||
if item.Status == nil {
|
||||
return true
|
||||
}
|
||||
return *item.Status == model.TaskItemStatusUnscheduled
|
||||
}
|
||||
|
||||
// buildTaskItemOrderLookup 为每个 task_item 构建稳定顺序号。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 优先使用数据库里的 item.Order,保持用户或上游生成的显式顺序;
|
||||
// 2. 若历史数据缺少 order,则退回 TaskClass.Items 当前顺序,保证写工具层仍有稳定边界;
|
||||
// 3. 只负责构建运行态映射,不回写数据库。
|
||||
func buildTaskItemOrderLookup(taskClasses []model.TaskClass) map[int]int {
|
||||
lookup := make(map[int]int)
|
||||
for _, tc := range taskClasses {
|
||||
for idx, item := range tc.Items {
|
||||
order := idx + 1
|
||||
if item.Order != nil && *item.Order > 0 {
|
||||
order = *item.Order
|
||||
}
|
||||
lookup[item.ID] = order
|
||||
}
|
||||
}
|
||||
return lookup
|
||||
}
|
||||
|
||||
// estimateTaskItemDuration 估算 pending 任务默认时长。
|
||||
//
|
||||
// 规则:若任务类声明了 total_slots,则按 total_slots / item_count 取整(最少 1);
|
||||
// 否则回退到 2 节。
|
||||
func estimateTaskItemDuration(tc model.TaskClass) int {
|
||||
duration := 2
|
||||
if tc.TotalSlots != nil && *tc.TotalSlots > 0 && len(tc.Items) > 0 {
|
||||
if d := *tc.TotalSlots / len(tc.Items); d > 0 {
|
||||
duration = d
|
||||
}
|
||||
}
|
||||
return duration
|
||||
}
|
||||
|
||||
// taskItemName 读取任务项展示名。
|
||||
func taskItemName(item model.TaskClassItem) string {
|
||||
if item.Content == nil {
|
||||
return ""
|
||||
}
|
||||
return *item.Content
|
||||
}
|
||||
|
||||
// slotsFromTargetTime 将 task_items.embedded_time 转换为 state 的槽位结构。
|
||||
// 若 target 为空、节次非法、或不在窗口内,返回 false。
|
||||
func slotsFromTargetTime(
|
||||
target *model.TargetTime,
|
||||
dayLookup map[[2]int]int,
|
||||
) ([]schedule.TaskSlot, bool) {
|
||||
if target == nil {
|
||||
return nil, false
|
||||
}
|
||||
if target.SectionFrom < 1 || target.SectionTo < target.SectionFrom {
|
||||
return nil, false
|
||||
}
|
||||
day, ok := dayLookup[[2]int{target.Week, target.DayOfWeek}]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return []schedule.TaskSlot{
|
||||
{
|
||||
Day: day,
|
||||
SlotStart: target.SectionFrom,
|
||||
SlotEnd: target.SectionTo,
|
||||
},
|
||||
}, true
|
||||
}
|
||||
|
||||
// ScheduleChangeType 表示两份 ScheduleState 对比后的变更类型。
|
||||
type ScheduleChangeType string
|
||||
|
||||
const (
|
||||
ChangePlace ScheduleChangeType = "place" // 从 pending 变为已放置
|
||||
ChangeMove ScheduleChangeType = "move" // 已有槽位发生移动
|
||||
ChangeUnplace ScheduleChangeType = "unplace" // 从已放置变回 pending
|
||||
)
|
||||
|
||||
// SlotCoord 表示数据库坐标系中的单节槽位(week/day_of_week/section)。
|
||||
type SlotCoord struct {
|
||||
Week int
|
||||
DayOfWeek int
|
||||
Section int
|
||||
}
|
||||
|
||||
// ScheduleChange 描述单个任务在前后状态间的变化。
|
||||
type ScheduleChange struct {
|
||||
Type ScheduleChangeType
|
||||
StateID int
|
||||
Source string // "event" | "task_item"
|
||||
SourceID int // ScheduleEvent.ID 或 TaskClassItem.ID
|
||||
EventType string // 仅 source=event 时有意义(course/task)
|
||||
CategoryID int // 仅 source=task_item 时有意义
|
||||
Name string
|
||||
|
||||
// place/move 的新位置(展开到逐节坐标)。
|
||||
NewCoords []SlotCoord
|
||||
// move/unplace 的旧位置(展开到逐节坐标)。
|
||||
OldCoords []SlotCoord
|
||||
|
||||
// HostEventID:变更后位置对应的宿主 event(非嵌入为 0)。
|
||||
HostEventID int
|
||||
// OldHostEventID:move 时旧位置对应的宿主 event(非嵌入为 0)。
|
||||
OldHostEventID int
|
||||
}
|
||||
|
||||
// DiffScheduleState 比较 original 与 modified,返回需要持久化的变更集合。
|
||||
func DiffScheduleState(
|
||||
original *schedule.ScheduleState,
|
||||
modified *schedule.ScheduleState,
|
||||
) []ScheduleChange {
|
||||
if original == nil || modified == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
origTasks := indexByStateID(original)
|
||||
var changes []ScheduleChange
|
||||
|
||||
for i := range modified.Tasks {
|
||||
mod := &modified.Tasks[i]
|
||||
orig := origTasks[mod.StateID]
|
||||
|
||||
wasPending := orig == nil || orig.Status == "pending"
|
||||
hasSlots := len(mod.Slots) > 0
|
||||
hadSlots := orig != nil && len(orig.Slots) > 0
|
||||
|
||||
switch {
|
||||
case wasPending && hasSlots:
|
||||
changes = append(changes, ScheduleChange{
|
||||
Type: ChangePlace,
|
||||
StateID: mod.StateID,
|
||||
Source: mod.Source,
|
||||
SourceID: mod.SourceID,
|
||||
EventType: mod.EventType,
|
||||
CategoryID: mod.CategoryID,
|
||||
Name: mod.Name,
|
||||
NewCoords: expandToCoords(mod.Slots, modified),
|
||||
HostEventID: resolveHostEventID(mod, modified),
|
||||
})
|
||||
case hadSlots && hasSlots && !slotsEqual(orig.Slots, mod.Slots):
|
||||
changes = append(changes, ScheduleChange{
|
||||
Type: ChangeMove,
|
||||
StateID: mod.StateID,
|
||||
Source: mod.Source,
|
||||
SourceID: mod.SourceID,
|
||||
EventType: mod.EventType,
|
||||
CategoryID: mod.CategoryID,
|
||||
Name: mod.Name,
|
||||
OldCoords: expandToCoords(orig.Slots, original),
|
||||
NewCoords: expandToCoords(mod.Slots, modified),
|
||||
HostEventID: resolveHostEventID(mod, modified),
|
||||
OldHostEventID: resolveHostEventID(orig, original),
|
||||
})
|
||||
case hadSlots && !hasSlots:
|
||||
changes = append(changes, ScheduleChange{
|
||||
Type: ChangeUnplace,
|
||||
StateID: mod.StateID,
|
||||
Source: orig.Source,
|
||||
SourceID: orig.SourceID,
|
||||
EventType: orig.EventType,
|
||||
Name: orig.Name,
|
||||
OldCoords: expandToCoords(orig.Slots, original),
|
||||
HostEventID: resolveHostEventID(orig, original),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
// indexByStateID 将任务列表按 state_id 建立索引。
|
||||
func indexByStateID(state *schedule.ScheduleState) map[int]*schedule.ScheduleTask {
|
||||
m := make(map[int]*schedule.ScheduleTask, len(state.Tasks))
|
||||
for i := range state.Tasks {
|
||||
m[state.Tasks[i].StateID] = &state.Tasks[i]
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// slotsEqual 判断两个压缩槽位切片是否完全一致。
|
||||
func slotsEqual(a, b []schedule.TaskSlot) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// cloneTaskSlots 深拷贝槽位切片。
|
||||
func cloneTaskSlots(src []schedule.TaskSlot) []schedule.TaskSlot {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
dst := make([]schedule.TaskSlot, len(src))
|
||||
copy(dst, src)
|
||||
return dst
|
||||
}
|
||||
|
||||
// resolveHostEventID 通过任务的 EmbedHost 反查宿主 event_id。
|
||||
// 非嵌入任务或宿主不存在时返回 0。
|
||||
func resolveHostEventID(task *schedule.ScheduleTask, state *schedule.ScheduleState) int {
|
||||
if task == nil || task.EmbedHost == nil {
|
||||
return 0
|
||||
}
|
||||
host := state.TaskByStateID(*task.EmbedHost)
|
||||
if host == nil {
|
||||
return 0
|
||||
}
|
||||
return host.SourceID
|
||||
}
|
||||
|
||||
// expandToCoords 将压缩槽位展开成逐节坐标,便于后续持久化层处理。
|
||||
func expandToCoords(slots []schedule.TaskSlot, state *schedule.ScheduleState) []SlotCoord {
|
||||
var coords []SlotCoord
|
||||
for _, slot := range slots {
|
||||
week, dow, ok := state.DayToWeekDay(slot.Day)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for sec := slot.SlotStart; sec <= slot.SlotEnd; sec++ {
|
||||
coords = append(coords, SlotCoord{Week: week, DayOfWeek: dow, Section: sec})
|
||||
}
|
||||
}
|
||||
return coords
|
||||
}
|
||||
90
backend/services/agent/conv/schedule_state_apply.go
Normal file
90
backend/services/agent/conv/schedule_state_apply.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package agentconv
|
||||
|
||||
import (
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
schedule "github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
)
|
||||
|
||||
// ApplyPlacedItems 将前端提交的绝对时间放置项应用到 ScheduleState。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只修改 source=task_item 的任务,source=event 的课程不受影响;
|
||||
// 2. 不在请求中的任务保持原样(slots/status/embed 不变);
|
||||
// 3. 不校验 Slots 的业务合法性(冲突等由 execute 节点兜底);
|
||||
// 4. 返回 respond.XXX 错误,调用方可直接透传给 DealWithError。
|
||||
func ApplyPlacedItems(
|
||||
state *schedule.ScheduleState,
|
||||
items []model.SaveScheduleStatePlacedItem,
|
||||
) error {
|
||||
// 1. 构建索引。
|
||||
sourceIDToTask := make(map[int]*schedule.ScheduleTask, len(state.Tasks))
|
||||
eventSourceIDToTask := make(map[int]*schedule.ScheduleTask)
|
||||
for i := range state.Tasks {
|
||||
t := &state.Tasks[i]
|
||||
if t.Source == "task_item" {
|
||||
sourceIDToTask[t.SourceID] = t
|
||||
} else if t.Source == "event" {
|
||||
eventSourceIDToTask[t.SourceID] = t
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 去重检查。
|
||||
seen := make(map[int]struct{}, len(items))
|
||||
for _, item := range items {
|
||||
if _, dup := seen[item.TaskItemID]; dup {
|
||||
return respond.ScheduleStateDuplicateTaskItem
|
||||
}
|
||||
seen[item.TaskItemID] = struct{}{}
|
||||
}
|
||||
|
||||
// 3. 逐个处理 item。
|
||||
for _, item := range items {
|
||||
// 3.1 绝对坐标 → 相对 day_index。
|
||||
dayIndex, ok := state.WeekDayToDay(item.Week, item.DayOfWeek)
|
||||
if !ok {
|
||||
return respond.ScheduleStateInvalidCoordinates
|
||||
}
|
||||
|
||||
// 3.2 在快照中查找对应的 task_item。
|
||||
task, found := sourceIDToTask[item.TaskItemID]
|
||||
if !found {
|
||||
return respond.ScheduleStateTaskItemNotFound
|
||||
}
|
||||
|
||||
// 3.3 清除旧嵌入关系。
|
||||
if task.EmbedHost != nil {
|
||||
oldHost := state.TaskByStateID(*task.EmbedHost)
|
||||
if oldHost != nil {
|
||||
oldHost.EmbeddedBy = nil
|
||||
}
|
||||
task.EmbedHost = nil
|
||||
}
|
||||
|
||||
// 3.4 设置新嵌入关系。
|
||||
if item.EmbedCourseEventID != 0 {
|
||||
hostEvent := eventSourceIDToTask[item.EmbedCourseEventID]
|
||||
if hostEvent == nil {
|
||||
return respond.ScheduleStateEventNotFound
|
||||
}
|
||||
hostStateID := hostEvent.StateID
|
||||
guestStateID := task.StateID
|
||||
task.EmbedHost = &hostStateID
|
||||
hostEvent.EmbeddedBy = &guestStateID
|
||||
}
|
||||
|
||||
// 3.5 更新 Slots。
|
||||
task.Slots = []schedule.TaskSlot{{
|
||||
Day: dayIndex,
|
||||
SlotStart: item.StartSection,
|
||||
SlotEnd: item.EndSection,
|
||||
}}
|
||||
|
||||
// 3.6 pending → suggested。
|
||||
if task.Status == schedule.TaskStatusPending {
|
||||
task.Status = schedule.TaskStatusSuggested
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
312
backend/services/agent/graph/common_graph.go
Normal file
312
backend/services/agent/graph/common_graph.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentnode "github.com/LoveLosita/smartflow/backend/services/agent/node"
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
|
||||
const (
|
||||
GraphName = "agent_loop"
|
||||
|
||||
NodeChat = "chat"
|
||||
NodePlan = "plan"
|
||||
NodeConfirm = "confirm"
|
||||
NodeRoughBuild = "rough_build"
|
||||
NodeExecute = "execute"
|
||||
NodeInterrupt = "interrupt"
|
||||
NodeDeliver = "deliver"
|
||||
NodeQuickTask = "quick_task"
|
||||
)
|
||||
|
||||
func RunAgentGraph(ctx context.Context, input agentmodel.AgentGraphRunInput) (*agentmodel.AgentGraphState, error) {
|
||||
state := agentmodel.NewAgentGraphState(input)
|
||||
if state == nil {
|
||||
return nil, errors.New("agent graph: graph state is nil")
|
||||
}
|
||||
|
||||
flowState := state.EnsureFlowState()
|
||||
if flowState == nil {
|
||||
return nil, errors.New("agent graph: flow state is nil")
|
||||
}
|
||||
|
||||
nodes := agentnode.NewAgentNodes()
|
||||
g := compose.NewGraph[*agentmodel.AgentGraphState, *agentmodel.AgentGraphState]()
|
||||
|
||||
// --- 注册节点 ---
|
||||
if err := g.AddLambdaNode(NodeChat, compose.InvokableLambda(nodes.Chat)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := g.AddLambdaNode(NodePlan, compose.InvokableLambda(nodes.Plan)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := g.AddLambdaNode(NodeConfirm, compose.InvokableLambda(nodes.Confirm)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := g.AddLambdaNode(NodeRoughBuild, compose.InvokableLambda(nodes.RoughBuild)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := g.AddLambdaNode(NodeExecute, compose.InvokableLambda(nodes.Execute)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := g.AddLambdaNode(NodeQuickTask, compose.InvokableLambda(nodes.QuickTask)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := g.AddLambdaNode(NodeInterrupt, compose.InvokableLambda(nodes.Interrupt)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := g.AddLambdaNode(NodeDeliver, compose.InvokableLambda(nodes.Deliver)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// --- 连边 ---
|
||||
// 1. 所有请求统一先过 chat 入口,这样普通聊天、首次任务、恢复执行都走同一入口。
|
||||
// 2. chat 不再负责旧式多业务图路由,只负责决定后续应该进入哪个统一节点。
|
||||
if err := g.AddEdge(compose.START, NodeChat); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Chat -> END / Plan / Confirm / RoughBuild / Execute / QuickTask / Deliver / Interrupt
|
||||
if err := g.AddBranch(NodeChat, compose.NewGraphBranch(
|
||||
branchAfterChat,
|
||||
map[string]bool{
|
||||
NodePlan: true,
|
||||
NodeConfirm: true,
|
||||
NodeRoughBuild: true,
|
||||
NodeExecute: true,
|
||||
NodeQuickTask: true,
|
||||
NodeDeliver: true,
|
||||
NodeInterrupt: true,
|
||||
compose.END: true,
|
||||
},
|
||||
)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Plan -> Plan(继续规划) / Confirm(规划完成) / RoughBuild(需粗排) / Execute(直接执行) / Deliver(完成) / Interrupt(需要追问用户)
|
||||
if err := g.AddBranch(NodePlan, compose.NewGraphBranch(
|
||||
branchAfterPlan,
|
||||
map[string]bool{
|
||||
NodePlan: true,
|
||||
NodeConfirm: true,
|
||||
NodeRoughBuild: true,
|
||||
NodeExecute: true,
|
||||
NodeDeliver: true,
|
||||
NodeInterrupt: true,
|
||||
},
|
||||
)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Confirm -> Plan(用户拒绝或重规划) / RoughBuild(需粗排) / Execute(直接执行) / Deliver(完成) / Interrupt(等待用户确认)
|
||||
if err := g.AddBranch(NodeConfirm, compose.NewGraphBranch(
|
||||
branchAfterConfirm,
|
||||
map[string]bool{
|
||||
NodePlan: true,
|
||||
NodeRoughBuild: true,
|
||||
NodeExecute: true,
|
||||
NodeDeliver: true,
|
||||
NodeInterrupt: true,
|
||||
},
|
||||
)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// RoughBuild -> Execute / Deliver:
|
||||
// 1. 正常粗排完成后进入 execute 微调;
|
||||
// 2. 若粗排阶段已写入正式终止结果(如粗排异常 abort),则直接进入 deliver 收口。
|
||||
if err := g.AddBranch(NodeRoughBuild, compose.NewGraphBranch(
|
||||
branchAfterRoughBuild,
|
||||
map[string]bool{
|
||||
NodeExecute: true,
|
||||
NodeDeliver: true,
|
||||
NodeInterrupt: true,
|
||||
},
|
||||
)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Execute -> Execute(继续 ReAct) / Confirm(写操作待确认) / Deliver(完成) / Interrupt(需要追问用户)
|
||||
if err := g.AddBranch(NodeExecute, compose.NewGraphBranch(
|
||||
branchAfterExecute,
|
||||
map[string]bool{
|
||||
NodeExecute: true,
|
||||
NodeConfirm: true,
|
||||
NodeDeliver: true,
|
||||
NodeInterrupt: true,
|
||||
},
|
||||
)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Interrupt -> END:当前连接必须在这里收口,等待用户输入或确认回调恢复。
|
||||
if err := g.AddEdge(NodeInterrupt, compose.END); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Deliver -> END
|
||||
if err := g.AddEdge(NodeDeliver, compose.END); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// QuickTask -> END:轻量路径,直接返回结果。
|
||||
if err := g.AddEdge(NodeQuickTask, compose.END); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// --- 编译运行 ---
|
||||
maxSteps := flowState.MaxRounds + 10
|
||||
runnable, err := g.Compile(ctx,
|
||||
compose.WithGraphName(GraphName),
|
||||
compose.WithMaxRunSteps(maxSteps),
|
||||
compose.WithNodeTriggerMode(compose.AnyPredecessor),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return runnable.Invoke(ctx, state)
|
||||
}
|
||||
|
||||
// --- 分支函数 ---
|
||||
|
||||
func branchAfterChat(_ context.Context, st *agentmodel.AgentGraphState) (string, error) {
|
||||
if st == nil {
|
||||
return compose.END, nil
|
||||
}
|
||||
if nextNode, interrupted := branchIfInterrupted(st); interrupted {
|
||||
return nextNode, nil
|
||||
}
|
||||
|
||||
flowState := st.EnsureFlowState()
|
||||
if flowState == nil {
|
||||
return compose.END, nil
|
||||
}
|
||||
switch flowState.Phase {
|
||||
case agentmodel.PhaseChatting:
|
||||
// 简单任务直接回复 / 深度回答完成,回复已在 Chat 节点生成。
|
||||
return compose.END, nil
|
||||
case agentmodel.PhasePlanning:
|
||||
return NodePlan, nil
|
||||
case agentmodel.PhaseWaitingConfirm:
|
||||
return NodeConfirm, nil
|
||||
case agentmodel.PhaseQuickTask:
|
||||
return NodeQuickTask, nil
|
||||
case agentmodel.PhaseExecuting:
|
||||
if flowState.NeedsRoughBuild && st.Deps.RoughBuildFunc != nil {
|
||||
return NodeRoughBuild, nil
|
||||
}
|
||||
return NodeExecute, nil
|
||||
case agentmodel.PhaseDone:
|
||||
return NodeDeliver, nil
|
||||
default:
|
||||
return compose.END, nil
|
||||
}
|
||||
}
|
||||
|
||||
func branchAfterPlan(_ context.Context, st *agentmodel.AgentGraphState) (string, error) {
|
||||
if st == nil {
|
||||
return NodePlan, nil
|
||||
}
|
||||
if nextNode, interrupted := branchIfInterrupted(st); interrupted {
|
||||
return nextNode, nil
|
||||
}
|
||||
|
||||
flowState := st.EnsureFlowState()
|
||||
if flowState == nil {
|
||||
return NodePlan, nil
|
||||
}
|
||||
if flowState.Phase == agentmodel.PhaseWaitingConfirm {
|
||||
return NodeConfirm, nil
|
||||
}
|
||||
if flowState.Phase == agentmodel.PhaseExecuting {
|
||||
if flowState.NeedsRoughBuild && st.Deps.RoughBuildFunc != nil {
|
||||
return NodeRoughBuild, nil
|
||||
}
|
||||
return NodeExecute, nil
|
||||
}
|
||||
if flowState.Phase == agentmodel.PhaseDone {
|
||||
return NodeDeliver, nil
|
||||
}
|
||||
return NodePlan, nil
|
||||
}
|
||||
|
||||
func branchAfterConfirm(_ context.Context, st *agentmodel.AgentGraphState) (string, error) {
|
||||
if st == nil {
|
||||
return NodePlan, nil
|
||||
}
|
||||
if nextNode, interrupted := branchIfInterrupted(st); interrupted {
|
||||
return nextNode, nil
|
||||
}
|
||||
|
||||
flowState := st.EnsureFlowState()
|
||||
if flowState == nil {
|
||||
return NodePlan, nil
|
||||
}
|
||||
switch flowState.Phase {
|
||||
case agentmodel.PhaseExecuting:
|
||||
// 若 Plan 节点标记了需要粗排且 RoughBuildFunc 已注入,走粗排节点。
|
||||
if flowState.NeedsRoughBuild && st.Deps.RoughBuildFunc != nil {
|
||||
return NodeRoughBuild, nil
|
||||
}
|
||||
return NodeExecute, nil
|
||||
case agentmodel.PhaseWaitingConfirm:
|
||||
// confirm 节点产出确认请求后,当前连接必须进入 interrupt 收口。
|
||||
// 真正的用户确认结果应由外部回调写回状态,再重新进入 graph。
|
||||
return NodeInterrupt, nil
|
||||
case agentmodel.PhaseDone:
|
||||
return NodeDeliver, nil
|
||||
default:
|
||||
return NodePlan, nil
|
||||
}
|
||||
}
|
||||
|
||||
func branchAfterRoughBuild(_ context.Context, st *agentmodel.AgentGraphState) (string, error) {
|
||||
if st == nil {
|
||||
return NodeExecute, nil
|
||||
}
|
||||
if nextNode, interrupted := branchIfInterrupted(st); interrupted {
|
||||
return nextNode, nil
|
||||
}
|
||||
|
||||
flowState := st.EnsureFlowState()
|
||||
if flowState == nil {
|
||||
return NodeExecute, nil
|
||||
}
|
||||
if flowState.Phase == agentmodel.PhaseDone {
|
||||
return NodeDeliver, nil
|
||||
}
|
||||
return NodeExecute, nil
|
||||
}
|
||||
|
||||
func branchAfterExecute(_ context.Context, st *agentmodel.AgentGraphState) (string, error) {
|
||||
if st == nil {
|
||||
return NodeExecute, nil
|
||||
}
|
||||
if nextNode, interrupted := branchIfInterrupted(st); interrupted {
|
||||
return nextNode, nil
|
||||
}
|
||||
|
||||
flowState := st.EnsureFlowState()
|
||||
if flowState == nil {
|
||||
return NodeExecute, nil
|
||||
}
|
||||
if flowState.Phase == agentmodel.PhaseWaitingConfirm {
|
||||
return NodeConfirm, nil
|
||||
}
|
||||
// 1. 这里只围绕“是否已经写入正式终止结果”做路由,避免把“刚好用完最后一轮预算”
|
||||
// 误判成已经 exhausted 收口;
|
||||
// 2. 真正的 exhausted 语义应由下一次 Execute 入口在 NextRound() 失败时统一写入,
|
||||
// 这样 rough_build / execute / deliver 才都围绕同一份 terminal outcome 工作;
|
||||
// 3. 若此处直接按 RoundUsed>=MaxRounds 跳 Deliver,会绕过 Execute 内的 Exhaust 写入,
|
||||
// 导致 deliver 收口和后续预览落盘语义不一致。
|
||||
if flowState.Phase == agentmodel.PhaseDone {
|
||||
return NodeDeliver, nil
|
||||
}
|
||||
return NodeExecute, nil
|
||||
}
|
||||
|
||||
func branchIfInterrupted(st *agentmodel.AgentGraphState) (string, bool) {
|
||||
if st == nil {
|
||||
return "", false
|
||||
}
|
||||
runtimeState := st.EnsureRuntimeState()
|
||||
if runtimeState != nil && runtimeState.HasPendingInteraction() {
|
||||
return NodeInterrupt, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
86
backend/services/agent/model/chat_contract.go
Normal file
86
backend/services/agent/model/chat_contract.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ChatRoute 表示 Chat 节点路由决策的目标路径。
|
||||
type ChatRoute string
|
||||
|
||||
const (
|
||||
// ChatRouteDirectReply 简单任务:Chat 节点直接输出回复,不再调用下游节点。
|
||||
ChatRouteDirectReply ChatRoute = "direct_reply"
|
||||
|
||||
// ChatRouteExecute 中等任务:需要用工具处理,直接进 Execute ReAct 循环。
|
||||
ChatRouteExecute ChatRoute = "execute"
|
||||
|
||||
// ChatRouteDeepAnswer 复杂问答:需要深度思考但不需工具,Chat 节点原地开 thinking 回答。
|
||||
ChatRouteDeepAnswer ChatRoute = "deep_answer"
|
||||
|
||||
// ChatRoutePlan 复杂规划:需要先制定计划,进 Plan 节点。
|
||||
ChatRoutePlan ChatRoute = "plan"
|
||||
|
||||
// ChatRouteQuickTask 快捷任务:随口记增查改删等轻量任务操作,走 QuickTask 轻量路径。
|
||||
ChatRouteQuickTask ChatRoute = "quick_task"
|
||||
)
|
||||
|
||||
// ChatRoutingDecision 是 Chat 节点单次路由决策的结构化输出。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. Route 决定后续处理路径;
|
||||
// 2. NeedsRoughBuild 仅在 route=execute 且满足粗排条件时为 true;
|
||||
// 3. NeedsRefineAfterRoughBuild 仅在 needs_rough_build=true 时有效;
|
||||
// 4. AllowReorder 表示是否允许打乱 suggested 任务顺序,仅用户明确授权时应为 true;
|
||||
// 5. Thinking 表示下游 Execute 节点是否应开启深度思考;
|
||||
// 6. Raw 保留控制码原文,供日志排查;
|
||||
// 7. 用户可见内容(speak)由流式输出自然产出,不由本结构承载。
|
||||
type ChatRoutingDecision struct {
|
||||
Route ChatRoute
|
||||
NeedsRoughBuild bool
|
||||
NeedsRefineAfterRoughBuild bool
|
||||
AllowReorder bool
|
||||
Thinking bool
|
||||
Raw string
|
||||
}
|
||||
|
||||
// Normalize 统一清洗路由决策中的字符串字段。
|
||||
func (d *ChatRoutingDecision) Normalize() {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
d.Route = ChatRoute(strings.TrimSpace(string(d.Route)))
|
||||
d.Raw = strings.TrimSpace(d.Raw)
|
||||
}
|
||||
|
||||
// Validate 校验路由决策的最小合法性。
|
||||
func (d *ChatRoutingDecision) Validate() error {
|
||||
if d == nil {
|
||||
return fmt.Errorf("chat routing decision 不能为空")
|
||||
}
|
||||
|
||||
d.Normalize()
|
||||
|
||||
switch d.Route {
|
||||
case ChatRouteDirectReply, ChatRouteExecute, ChatRouteDeepAnswer, ChatRoutePlan, ChatRouteQuickTask:
|
||||
// ok
|
||||
case "":
|
||||
return fmt.Errorf("chat routing decision.route 不能为空")
|
||||
default:
|
||||
return fmt.Errorf("未知 route: %s", d.Route)
|
||||
}
|
||||
|
||||
// 非 execute 路由不应携带粗排和粗排后微调标记,统一归一化为 false。
|
||||
if d.Route != ChatRouteExecute {
|
||||
d.NeedsRoughBuild = false
|
||||
d.NeedsRefineAfterRoughBuild = false
|
||||
d.AllowReorder = false
|
||||
d.Thinking = false
|
||||
}
|
||||
// 只有 needs_rough_build=true 时,needs_refine_after_rough_build 才有语义。
|
||||
if !d.NeedsRoughBuild {
|
||||
d.NeedsRefineAfterRoughBuild = false
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
512
backend/services/agent/model/common_state.go
Normal file
512
backend/services/agent/model/common_state.go
Normal file
@@ -0,0 +1,512 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
schedule "github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
)
|
||||
|
||||
// Phase 表示 agent 主循环当前所处的大阶段。
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhasePlanning Phase = "planning"
|
||||
PhaseWaitingConfirm Phase = "waiting_confirm"
|
||||
PhaseExecuting Phase = "executing"
|
||||
PhaseQuickTask Phase = "quick_task"
|
||||
PhaseDone Phase = "done"
|
||||
)
|
||||
|
||||
// FlowTerminalStatus 表示本轮流程最终是如何结束的。
|
||||
//
|
||||
// 说明:
|
||||
// 1. completed 表示任务按预期完成,允许走正常交付与预览落盘;
|
||||
// 2. aborted 表示业务语义上的主动终止,例如粗排异常、执行期明确中止;
|
||||
// 3. exhausted 表示安全边界触发的被动停止,例如执行轮次耗尽。
|
||||
type FlowTerminalStatus string
|
||||
|
||||
const (
|
||||
FlowTerminalStatusCompleted FlowTerminalStatus = "completed"
|
||||
FlowTerminalStatusAborted FlowTerminalStatus = "aborted"
|
||||
FlowTerminalStatusExhausted FlowTerminalStatus = "exhausted"
|
||||
)
|
||||
|
||||
// FlowTerminalOutcome 保存"流程为什么结束"的最终结果快照。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. Stage 说明终止发生在哪个阶段,便于 graph/deliver/debug 统一收口;
|
||||
// 2. Code 作为稳定机器码,便于后续前端或埋点按类型识别;
|
||||
// 3. UserMessage 是最终给用户看的收口文案;
|
||||
// 4. InternalReason 只用于日志与排查,不直接暴露给用户。
|
||||
type FlowTerminalOutcome struct {
|
||||
Status FlowTerminalStatus `json:"status"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
UserMessage string `json:"user_message,omitempty"`
|
||||
InternalReason string `json:"internal_reason,omitempty"`
|
||||
}
|
||||
|
||||
// Normalize 统一清洗终止结果里的字符串字段。
|
||||
func (o *FlowTerminalOutcome) Normalize() {
|
||||
if o == nil {
|
||||
return
|
||||
}
|
||||
o.Status = FlowTerminalStatus(strings.TrimSpace(string(o.Status)))
|
||||
o.Stage = strings.TrimSpace(o.Stage)
|
||||
o.Code = strings.TrimSpace(o.Code)
|
||||
o.UserMessage = strings.TrimSpace(o.UserMessage)
|
||||
o.InternalReason = strings.TrimSpace(o.InternalReason)
|
||||
}
|
||||
|
||||
const DefaultMaxRounds = 60
|
||||
|
||||
// CommonState 承载可持久化的主流程状态。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责记录"当前处于哪个阶段、当前计划是什么、执行到了第几步、已经消耗了多少轮";
|
||||
// 2. 负责提供最小必要的安全访问方法,避免 graph/node/prompt 层到处手写切片越界判断;
|
||||
// 3. 不负责承载对话历史、tool schema、pinned context 这类模型输入材料,它们仍然属于 ConversationContext。
|
||||
type CommonState struct {
|
||||
// 身份信息
|
||||
TraceID string `json:"trace_id"`
|
||||
UserID int `json:"user_id"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
// ActiveToolDomain 记录当前 msg0 动态区激活的业务工具域。
|
||||
// 说明:
|
||||
// 1. 空字符串表示仅保留 context 管理工具,不注入业务工具定义;
|
||||
// 2. 非空时仅允许注入对应域的工具(如 schedule/taskclass);
|
||||
// 3. 该字段由 context_tools_add/remove 工具结果驱动更新。
|
||||
ActiveToolDomain string `json:"active_tool_domain,omitempty"`
|
||||
// ActiveToolPacks 记录当前激活域下的可选二级包(不含 core 固定包)。
|
||||
// 说明:
|
||||
// 1. 仅对 schedule 域生效(queue/mutation/analyze/web);
|
||||
// 2. 为空时按域默认策略解释(schedule 兼容为“全可选包”);
|
||||
// 3. 该字段与 ActiveToolDomain 一起由 context_tools_add/remove 结果更新。
|
||||
ActiveToolPacks []string `json:"active_tool_packs,omitempty"`
|
||||
// PendingContextHook 保存 plan 阶段给 execute 阶段的一次性注入建议。
|
||||
// 说明:
|
||||
// 1. 可由 plan_done 或 rough_build->execute 分支写入;
|
||||
// 2. execute 首轮消费一次后清空;
|
||||
// 3. 该字段只表达建议,不直接触发工具调用。
|
||||
PendingContextHook *ContextHook `json:"pending_context_hook,omitempty"`
|
||||
|
||||
// 流程阶段
|
||||
Phase Phase `json:"phase"`
|
||||
|
||||
// 计划状态
|
||||
// 1. 这里直接使用结构化的 PlanStep,避免 planning -> execute 之间丢失 done_when。
|
||||
// 2. CurrentStep 表示"当前 plan 步骤下标",不是 execute 内部 ReAct 的思考轮次。
|
||||
PlanSteps []PlanStep `json:"plan_steps"`
|
||||
CurrentStep int `json:"current_step"`
|
||||
|
||||
// 安全边界
|
||||
MaxRounds int `json:"max_rounds"`
|
||||
RoundUsed int `json:"round_used"`
|
||||
|
||||
// 连续修正计数:LLM 连续输出不合法决策的次数,超过阈值后强制终止避免死循环。
|
||||
ConsecutiveCorrections int `json:"consecutive_corrections"`
|
||||
|
||||
// TaskClassIDs 本次排课请求涉及的任务类 ID 列表,由前端 extra.task_class_ids 传入。
|
||||
// Plan 节点据此判断是否需要粗排;跨轮次持久化,不会因会话恢复而丢失。
|
||||
TaskClassIDs []int `json:"task_class_ids,omitempty"`
|
||||
// TaskClasses 本次排课涉及的任务类约束元数据(含日期、策略、时段预算等),
|
||||
// 在 Service 层从 DB 加载并注入,供 Plan prompt 直接消费,避免 LLM 因信息不足而追问用户。
|
||||
TaskClasses []schedule.TaskClassMeta `json:"task_classes,omitempty"`
|
||||
// NeedsRoughBuild 由 Plan 节点在 plan_done 时写入,标记 Confirm 后是否需要走粗排节点。
|
||||
// 粗排节点执行完毕后会将此字段重置为 false。
|
||||
NeedsRoughBuild bool `json:"needs_rough_build,omitempty"`
|
||||
// NeedsRefineAfterRoughBuild 表示"粗排完成后是否需要立即进入微调"。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 该标记主要用于 chat->execute 的直执行链路;
|
||||
// 2. true 表示用户已明确提出优化偏好,粗排后继续进 execute 微调;
|
||||
// 3. false 表示用户仅要求完成排入,粗排成功后可直接收口,等待后续再优化。
|
||||
NeedsRefineAfterRoughBuild bool `json:"needs_refine_after_rough_build,omitempty"`
|
||||
// AllowReorder 表示本轮是否允许打乱 suggested 任务的相对顺序。
|
||||
// 默认 false,只有用户明确说明"可以打乱顺序/顺序不重要"才会为 true。
|
||||
AllowReorder bool `json:"allow_reorder,omitempty"`
|
||||
OptimizationMode string `json:"optimization_mode,omitempty"`
|
||||
// ActiveOptimizeOnly 标记“当前是否处于粗排后主动优化专用模式”。
|
||||
// 1. true 时,execute 只向 LLM 暴露 analyze_health + move + swap 这组最小闭环工具;
|
||||
// 2. 该开关只用于首次粗排后的自动微调,不影响用户后续明确提出的日程调整请求;
|
||||
// 3. 流程收口、重开新请求或切换业务域后,必须重置为 false。
|
||||
ActiveOptimizeOnly bool `json:"active_optimize_only,omitempty"`
|
||||
HealthCheckDone bool `json:"health_check_done,omitempty"`
|
||||
HealthIsFeasible bool `json:"health_is_feasible,omitempty"`
|
||||
HealthCapacityGap int `json:"health_capacity_gap,omitempty"`
|
||||
HealthReasonCode string `json:"health_reason_code,omitempty"`
|
||||
// HealthShouldContinueOptimize 记录最近一次 analyze_health 是否认为“还值得继续优化”。
|
||||
// 调用目的:
|
||||
// 1. 让 execute prompt 直接读取后端诊断结论,而不是只根据 issues 猜下一步;
|
||||
// 2. 该字段只表达“是否值得继续动”,不替 LLM 决定具体写参数;
|
||||
// 3. 默认 false,只有 analyze_health 明确判定后才会更新。
|
||||
HealthShouldContinueOptimize bool `json:"health_should_continue_optimize,omitempty"`
|
||||
// HealthTightnessLevel 记录最近一次诊断得到的优化空间等级:loose / tight / locked。
|
||||
// 调用目的:
|
||||
// 1. 用于提示 LLM 区分“还能优化”和“已经是被迫不完美”;
|
||||
// 2. 该字段只服务主动优化链路,不参与粗排可行性判断;
|
||||
// 3. 空字符串表示尚未拿到有效诊断。
|
||||
HealthTightnessLevel string `json:"health_tightness_level,omitempty"`
|
||||
// HealthPrimaryProblem 保存最近一次诊断的主要局部问题摘要。
|
||||
// 调用目的:
|
||||
// 1. 帮助 execute 聚焦当前最值得处理的那个点,避免全局乱搜;
|
||||
// 2. 只保存短摘要,不保存完整工具原文,避免状态膨胀;
|
||||
// 3. 为空表示当前没有明确主问题或诊断失败。
|
||||
HealthPrimaryProblem string `json:"health_primary_problem,omitempty"`
|
||||
// HealthRecommendedOperation 保存最近一次诊断建议优先考虑的动作类型。
|
||||
// 允许值由 analyze_health 控制,当前主要为 swap / move / close / ask_user。
|
||||
HealthRecommendedOperation string `json:"health_recommended_operation,omitempty"`
|
||||
// HealthIsForcedImperfection 标记当前剩余问题是否更像“约束代价”而非“仍值得修”的问题。
|
||||
// 调用目的:
|
||||
// 1. 给 LLM 一个明确的收口信号;
|
||||
// 2. 仅在 analyze_health 返回结构化 decision 时更新;
|
||||
// 3. false 不代表一定要继续优化,只代表“不是明确的被迫不完美”。
|
||||
HealthIsForcedImperfection bool `json:"health_is_forced_imperfection,omitempty"`
|
||||
// HealthImprovementSignal 保存最近一次诊断的紧凑对比信号,用于判断是否连续停滞。
|
||||
// 调用目的:
|
||||
// 1. execute 可基于该字段识别“连续两轮几乎没改善”;
|
||||
// 2. 信号由 analyze_health 生成,格式稳定但不面向用户展示;
|
||||
// 3. 若诊断失败则保持空字符串。
|
||||
HealthImprovementSignal string `json:"health_improvement_signal,omitempty"`
|
||||
// HealthStagnationCount 记录连续多少次 analyze_health 给出了相同的 improvement_signal。
|
||||
// 调用目的:
|
||||
// 1. 让 prompt 可以在“继续磨也没明显改善”时提醒 LLM 主动收口;
|
||||
// 2. 仅在两次连续有效诊断的信号完全相同时递增;
|
||||
// 3. 只做软提醒,不做后端硬拦截。
|
||||
HealthStagnationCount int `json:"health_stagnation_count,omitempty"`
|
||||
// TaskClassUpsertLastTried 标记本轮是否至少调用过一次 upsert_task_class。
|
||||
// 调用目的:execute_context 仅在该标记为 true 时注入“最近一次任务类写入结果”,避免噪音。
|
||||
TaskClassUpsertLastTried bool `json:"task_class_upsert_last_tried,omitempty"`
|
||||
// TaskClassUpsertLastSuccess 记录最近一次 upsert_task_class 是否成功。
|
||||
// 调用目的:为 prompt 提供“是否需要继续追问补字段”的明确信号。
|
||||
TaskClassUpsertLastSuccess bool `json:"task_class_upsert_last_success,omitempty"`
|
||||
// TaskClassUpsertLastIssues 记录最近一次写入返回的校验问题(validation.issues)。
|
||||
// 调用目的:让 LLM 直接按缺失字段追问,减少泛化提问。
|
||||
TaskClassUpsertLastIssues []string `json:"task_class_upsert_last_issues,omitempty"`
|
||||
// TaskClassUpsertConsecutiveFailures 记录连续写入失败次数。
|
||||
// 调用目的:给 prompt 注入“避免空转”的软提示,不做硬拦截。
|
||||
TaskClassUpsertConsecutiveFailures int `json:"task_class_upsert_consecutive_failures,omitempty"`
|
||||
// HasScheduleWriteOps 标记本轮 execute 循环是否执行过日程写工具。
|
||||
// 调用目的:为 prompt/收口层提供“本轮是否真的动过日程写工具”的运行态信号。
|
||||
HasScheduleWriteOps bool `json:"has_schedule_write_ops,omitempty"`
|
||||
// UsedQuickNote 标记本轮是否走过“快捷随口记任务”路径。
|
||||
// 调用目的:graph 完成后据此决定是否跳过记忆抽取,避免随口记内容被错误归类。
|
||||
UsedQuickNote bool `json:"used_quick_note,omitempty"`
|
||||
// HasScheduleChanges 标记本轮流程是否产生过日程变更(粗排或写工具)。
|
||||
// 调用目的:deliver 节点据此判断是否向前端推送"排程完毕"卡片。
|
||||
HasScheduleChanges bool `json:"has_schedule_changes,omitempty"`
|
||||
|
||||
// ExecuteThinking 由 Chat 路由决策传入,表示 Execute 节点是否应开启深度思考。
|
||||
// 预埋字段,当前阶段 Execute 节点可自行决定是否读取。
|
||||
ExecuteThinking bool `json:"execute_thinking,omitempty"`
|
||||
|
||||
// ThinkingMode 由前端传入,控制所有下游 LLM 调用的 thinking 行为。
|
||||
// "true" 强制开启,"false" 强制关闭,"auto"(默认)交给路由决策。
|
||||
ThinkingMode string `json:"thinking_mode,omitempty"`
|
||||
|
||||
// TerminalOutcome 保存"本轮流程最终如何结束"的统一收口结果。
|
||||
// 第二轮开始,rough_build / execute / deliver 都应围绕这份快照判断收口语义。
|
||||
TerminalOutcome *FlowTerminalOutcome `json:"terminal_outcome,omitempty"`
|
||||
}
|
||||
|
||||
func NewCommonState(traceID string, userID int, conversationID string) *CommonState {
|
||||
return &CommonState{
|
||||
TraceID: traceID,
|
||||
UserID: userID,
|
||||
ConversationID: conversationID,
|
||||
Phase: PhasePlanning,
|
||||
MaxRounds: DefaultMaxRounds,
|
||||
}
|
||||
}
|
||||
|
||||
// NextRound 消耗一轮预算,并返回当前是否仍在允许范围内。
|
||||
func (s *CommonState) NextRound() bool {
|
||||
s.RoundUsed++
|
||||
return s.RoundUsed <= s.MaxRounds
|
||||
}
|
||||
|
||||
// Exhausted 判断是否已经耗尽轮次预算。
|
||||
func (s *CommonState) Exhausted() bool {
|
||||
return s.RoundUsed >= s.MaxRounds
|
||||
}
|
||||
|
||||
// FinishPlan 在 planning 完成后固化完整计划,并推进到待确认阶段。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 直接保存完整的 []PlanStep,避免 execute 阶段再去依赖 pinned context 回捞完成判定;
|
||||
// 2. 统一把 CurrentStep 重置到第 0 步,保证后续 confirm/execute 都从计划开头进入;
|
||||
// 3. 这里只负责状态切换,不负责刷新 ConversationContext 中的置顶 plan 文本。
|
||||
func (s *CommonState) FinishPlan(steps []PlanStep) {
|
||||
s.PlanSteps = steps
|
||||
s.CurrentStep = 0
|
||||
s.Phase = PhaseWaitingConfirm
|
||||
s.ActiveToolDomain = ""
|
||||
s.ActiveToolPacks = nil
|
||||
s.PendingContextHook = nil
|
||||
s.NeedsRefineAfterRoughBuild = false
|
||||
s.ActiveOptimizeOnly = false
|
||||
s.resetTaskClassUpsertSnapshot()
|
||||
s.ClearTerminalOutcome()
|
||||
}
|
||||
|
||||
// ConfirmPlan 表示用户已确认计划,流程进入执行阶段。
|
||||
func (s *CommonState) ConfirmPlan() {
|
||||
s.Phase = PhaseExecuting
|
||||
s.NeedsRefineAfterRoughBuild = false
|
||||
s.ActiveOptimizeOnly = false
|
||||
s.resetTaskClassUpsertSnapshot()
|
||||
s.ClearTerminalOutcome()
|
||||
}
|
||||
|
||||
// StartDirectExecute 进入无 plan 的直接执行(ReAct)模式。
|
||||
// Chat 节点路由到 execute 时必须调用此方法,而非直接赋值 Phase,
|
||||
// 否则上一次任务残留的 PlanSteps 会被 HasPlan() 误判为仍有计划,
|
||||
// 导致 Execute 节点用旧步骤跑 plan 模式而非 ReAct 模式。
|
||||
func (s *CommonState) StartDirectExecute() {
|
||||
s.PlanSteps = nil
|
||||
s.CurrentStep = 0
|
||||
s.Phase = PhaseExecuting
|
||||
s.ActiveToolDomain = ""
|
||||
s.ActiveToolPacks = nil
|
||||
s.PendingContextHook = nil
|
||||
s.NeedsRoughBuild = false
|
||||
s.NeedsRefineAfterRoughBuild = false
|
||||
s.ActiveOptimizeOnly = false
|
||||
s.resetTaskClassUpsertSnapshot()
|
||||
s.ClearTerminalOutcome()
|
||||
}
|
||||
|
||||
// RejectPlan 表示用户拒绝当前计划,清空计划并回退到 planning。
|
||||
func (s *CommonState) RejectPlan() {
|
||||
s.PlanSteps = nil
|
||||
s.CurrentStep = 0
|
||||
s.Phase = PhasePlanning
|
||||
s.ActiveToolDomain = ""
|
||||
s.ActiveToolPacks = nil
|
||||
s.PendingContextHook = nil
|
||||
s.NeedsRefineAfterRoughBuild = false
|
||||
s.ActiveOptimizeOnly = false
|
||||
s.resetTaskClassUpsertSnapshot()
|
||||
s.ClearTerminalOutcome()
|
||||
}
|
||||
|
||||
// ResetForNextRun 在"上一轮已经收口,且本轮准备开始新请求"时重置执行期临时状态。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责清理会污染新一轮执行的临时字段(轮次、修正计数、计划游标、粗排开关、顺序基线、终止结果);
|
||||
// 2. 不负责清理会话身份与跨轮共享数据(ConversationID/UserID/TaskClassIDs/TaskClasses/历史上下文/ScheduleState);
|
||||
// 3. 该方法是幂等操作:重复调用不会引入额外副作用,便于在"加载兜底 + chat 入口"双保险场景下复用。
|
||||
func (s *CommonState) ResetForNextRun() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 先把阶段回收为 planning,确保新一轮从可路由的干净入口开始。
|
||||
// 2. 这样即使后续还有兜底重置判断,也不会因为仍处于 done 而重复触发。
|
||||
s.Phase = PhasePlanning
|
||||
|
||||
// 3. 清理执行轮次与连续修正计数,避免上一轮预算/异常计数污染本轮。
|
||||
s.RoundUsed = 0
|
||||
s.ConsecutiveCorrections = 0
|
||||
|
||||
// 4. 清理计划执行游标与粗排相关临时标记,确保新请求不会误沿用旧计划。
|
||||
s.PlanSteps = nil
|
||||
s.CurrentStep = 0
|
||||
s.ActiveToolDomain = ""
|
||||
s.ActiveToolPacks = nil
|
||||
s.PendingContextHook = nil
|
||||
s.NeedsRoughBuild = false
|
||||
s.NeedsRefineAfterRoughBuild = false
|
||||
s.ActiveOptimizeOnly = false
|
||||
|
||||
// 5. 重置顺序约束临时态与终止结果,避免上一轮 completed/aborted/exhausted 语义串到下一轮。
|
||||
s.AllowReorder = false
|
||||
s.OptimizationMode = ""
|
||||
s.HealthCheckDone = false
|
||||
s.HealthIsFeasible = true
|
||||
s.HealthCapacityGap = 0
|
||||
s.HealthReasonCode = ""
|
||||
s.HealthShouldContinueOptimize = false
|
||||
s.HealthTightnessLevel = ""
|
||||
s.HealthPrimaryProblem = ""
|
||||
s.HealthRecommendedOperation = ""
|
||||
s.HealthIsForcedImperfection = false
|
||||
s.HealthImprovementSignal = ""
|
||||
s.HealthStagnationCount = 0
|
||||
s.HasScheduleWriteOps = false
|
||||
s.HasScheduleChanges = false
|
||||
s.UsedQuickNote = false
|
||||
s.resetTaskClassUpsertSnapshot()
|
||||
s.ClearTerminalOutcome()
|
||||
}
|
||||
|
||||
// resetTaskClassUpsertSnapshot 清理“任务类写入回盘”运行态。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 仅清理 upsert_task_class 相关的临时回盘字段;
|
||||
// 2. 不影响 Health/Plan/Phase 等其他执行状态;
|
||||
// 3. 作为新一轮入口统一调用,避免旧失败信息污染本轮追问。
|
||||
func (s *CommonState) resetTaskClassUpsertSnapshot() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.TaskClassUpsertLastTried = false
|
||||
s.TaskClassUpsertLastSuccess = false
|
||||
s.TaskClassUpsertLastIssues = nil
|
||||
s.TaskClassUpsertConsecutiveFailures = 0
|
||||
}
|
||||
|
||||
// AdvanceStep 推进到下一个计划步骤,并返回是否仍有剩余步骤。
|
||||
func (s *CommonState) AdvanceStep() bool {
|
||||
s.CurrentStep++
|
||||
return s.CurrentStep < len(s.PlanSteps)
|
||||
}
|
||||
|
||||
// Done 标记整个任务流程已经结束。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 若此前已经写入 aborted / exhausted 等终止结果,这里只负责兜底维持 PhaseDone,不覆盖已有语义;
|
||||
// 2. 只有在尚未写入任何终止结果时,才默认补成 completed。
|
||||
func (s *CommonState) Done() {
|
||||
s.Phase = PhaseDone
|
||||
// 收口时自动清空工具域,确保下一轮 msg0 动态区回到最小集合(仅 context 管理工具)。
|
||||
// 调用目的:把“收尾清理”从 LLM 决策中剥离,减少 done 阶段无关 tool_call 噪音。
|
||||
s.ActiveToolDomain = ""
|
||||
s.ActiveToolPacks = nil
|
||||
s.PendingContextHook = nil
|
||||
s.ActiveOptimizeOnly = false
|
||||
if s.TerminalOutcome != nil {
|
||||
s.TerminalOutcome.Normalize()
|
||||
return
|
||||
}
|
||||
s.TerminalOutcome = &FlowTerminalOutcome{
|
||||
Status: FlowTerminalStatusCompleted,
|
||||
}
|
||||
}
|
||||
|
||||
// Abort 将当前流程标记为"业务语义上的主动终止"。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 统一写入 PhaseDone,保证 graph 后续直接进入 deliver 收口;
|
||||
// 2. UserMessage 作为最终可见文案,必须尽量完整,避免 deliver 再二次猜测;
|
||||
// 3. InternalReason 只用于排查,允许比用户文案更技术化。
|
||||
func (s *CommonState) Abort(stage, code, userMessage, internalReason string) {
|
||||
s.Phase = PhaseDone
|
||||
s.TerminalOutcome = &FlowTerminalOutcome{
|
||||
Status: FlowTerminalStatusAborted,
|
||||
Stage: stage,
|
||||
Code: code,
|
||||
UserMessage: userMessage,
|
||||
InternalReason: internalReason,
|
||||
}
|
||||
s.TerminalOutcome.Normalize()
|
||||
}
|
||||
|
||||
// Exhaust 将当前流程标记为"安全边界触发的被动停止"。
|
||||
func (s *CommonState) Exhaust(stage, userMessage, internalReason string) {
|
||||
s.Phase = PhaseDone
|
||||
s.TerminalOutcome = &FlowTerminalOutcome{
|
||||
Status: FlowTerminalStatusExhausted,
|
||||
Stage: stage,
|
||||
Code: "round_exhausted",
|
||||
UserMessage: userMessage,
|
||||
InternalReason: internalReason,
|
||||
}
|
||||
s.TerminalOutcome.Normalize()
|
||||
}
|
||||
|
||||
// ClearTerminalOutcome 清空上一轮遗留的终止结果。
|
||||
func (s *CommonState) ClearTerminalOutcome() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.TerminalOutcome = nil
|
||||
}
|
||||
|
||||
// HasTerminalOutcome 判断当前是否已经写入正式终止结果。
|
||||
func (s *CommonState) HasTerminalOutcome() bool {
|
||||
return s != nil && s.TerminalOutcome != nil
|
||||
}
|
||||
|
||||
// TerminalStatus 返回当前终止结果的状态枚举。
|
||||
func (s *CommonState) TerminalStatus() FlowTerminalStatus {
|
||||
if s == nil || s.TerminalOutcome == nil {
|
||||
return ""
|
||||
}
|
||||
return s.TerminalOutcome.Status
|
||||
}
|
||||
|
||||
// IsCompleted 判断当前是否属于"正常完成"。
|
||||
func (s *CommonState) IsCompleted() bool {
|
||||
return s.TerminalStatus() == FlowTerminalStatusCompleted
|
||||
}
|
||||
|
||||
// IsAborted 判断当前是否属于"主动中止"。
|
||||
func (s *CommonState) IsAborted() bool {
|
||||
return s.TerminalStatus() == FlowTerminalStatusAborted
|
||||
}
|
||||
|
||||
// IsExhaustedTerminal 判断当前是否属于"轮次耗尽收口"。
|
||||
func (s *CommonState) IsExhaustedTerminal() bool {
|
||||
return s.TerminalStatus() == FlowTerminalStatusExhausted
|
||||
}
|
||||
|
||||
// HasPlan 判断当前 state 是否已经持有一份完整计划。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责收口"是否存在 plan"这一层判断,避免外层到处写 len(PlanSteps) > 0;
|
||||
// 2. 不判断 CurrentStep 当前是否有效,当前步骤是否合法由 HasCurrentPlanStep 回答;
|
||||
// 3. state 为空时统一返回 false,调用方可据此决定是否回退到 planning。
|
||||
func (s *CommonState) HasPlan() bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
return len(s.PlanSteps) > 0
|
||||
}
|
||||
|
||||
// CurrentPlanStep 返回当前正在执行的结构化计划步骤。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责根据 CurrentStep 安全读取 PlanSteps,避免 graph/node/prompt 层重复写越界判断;
|
||||
// 2. 若 state 为空、plan 为空、或当前索引越界,则统一返回 (PlanStep{}, false);
|
||||
// 3. 不负责推进步骤,也不负责修正 CurrentStep 的取值。
|
||||
func (s *CommonState) CurrentPlanStep() (PlanStep, bool) {
|
||||
if s == nil {
|
||||
return PlanStep{}, false
|
||||
}
|
||||
if s.CurrentStep < 0 || s.CurrentStep >= len(s.PlanSteps) {
|
||||
return PlanStep{}, false
|
||||
}
|
||||
return s.PlanSteps[s.CurrentStep], true
|
||||
}
|
||||
|
||||
// HasCurrentPlanStep 判断"当前步骤"是否存在且可安全读取。
|
||||
func (s *CommonState) HasCurrentPlanStep() bool {
|
||||
_, ok := s.CurrentPlanStep()
|
||||
return ok
|
||||
}
|
||||
|
||||
// PlanProgress 返回当前计划的执行进度。
|
||||
//
|
||||
// 输出语义:
|
||||
// 1. current 使用更适合给用户看的 1-based 序号;
|
||||
// 2. total 表示当前计划的总步数;
|
||||
// 3. 若当前还没有计划,则返回 (0, 0);
|
||||
// 4. 若 CurrentStep 已越界到末尾之后,则把 current 收敛到 total,避免出现 total+1 这种噪音值。
|
||||
func (s *CommonState) PlanProgress() (current int, total int) {
|
||||
if s == nil {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
total = len(s.PlanSteps)
|
||||
if total == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
if s.CurrentStep < 0 {
|
||||
return 0, total
|
||||
}
|
||||
if s.CurrentStep >= total {
|
||||
return total, total
|
||||
}
|
||||
return s.CurrentStep + 1, total
|
||||
}
|
||||
212
backend/services/agent/model/conversation_context.go
Normal file
212
backend/services/agent/model/conversation_context.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// ConversationContext 承载"本轮要喂给模型的输入材料"。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责保存 system prompt、对话历史、置顶注入块、工具 schema 摘要;
|
||||
// 2. 负责提供最小必要的安全访问方法,避免 node / prompt 层直接散落切片操作;
|
||||
// 3. 不负责流程推进,phase / round / current step 仍归 CommonState 管;
|
||||
// 4. 不负责真正的 prompt 组装,消息如何拼接仍应放在 prompt 层处理。
|
||||
type ConversationContext struct {
|
||||
SystemPrompt string `json:"system_prompt"`
|
||||
History []*schema.Message `json:"history"`
|
||||
PinnedBlocks []ContextBlock `json:"pinned_blocks"`
|
||||
ToolSchemas []ToolSchemaContext `json:"-"` // 每次请求由 Service 层重新注入,不持久化
|
||||
}
|
||||
|
||||
// ContextBlock 表示一段可被"置顶注入"的自然语言上下文。
|
||||
//
|
||||
// 设计目的:
|
||||
// 1. Key 用于让调用方按语义覆盖,例如 current_plan / current_step / execution_rule;
|
||||
// 2. Title 用于 prompt 层后续决定是否渲染成小标题;
|
||||
// 3. Content 存真正的自然语言内容,保持你当前"plan 用自然语言表达"的思路。
|
||||
type ContextBlock struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// ToolSchemaContext 是工具描述的轻量快照。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只保留 prompt 注入真正需要的摘要信息;
|
||||
// 2. SchemaText 约定存"已经整理好的自然语言 / JSON schema 摘要";
|
||||
// 3. 不直接耦合具体 tool registry 里的复杂结构,避免 model 层反向依赖工具实现。
|
||||
type ToolSchemaContext struct {
|
||||
Name string `json:"name"`
|
||||
Desc string `json:"desc"`
|
||||
SchemaText string `json:"schema_text"`
|
||||
}
|
||||
|
||||
// NewConversationContext 创建最小上下文容器。
|
||||
func NewConversationContext(systemPrompt string) *ConversationContext {
|
||||
return &ConversationContext{
|
||||
SystemPrompt: strings.TrimSpace(systemPrompt),
|
||||
}
|
||||
}
|
||||
|
||||
// SetSystemPrompt 更新系统提示词。
|
||||
func (c *ConversationContext) SetSystemPrompt(systemPrompt string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.SystemPrompt = strings.TrimSpace(systemPrompt)
|
||||
}
|
||||
|
||||
// ReplaceHistory 整体替换对话历史。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把"会话快照恢复"这类场景需要的一次性覆盖入口收口到这里;
|
||||
// 2. 只复制消息切片本身,避免调用方后续 append 污染同一底层数组;
|
||||
// 3. 不深拷贝每个 message 指针,消息对象本身仍默认由上游按只读方式使用。
|
||||
func (c *ConversationContext) ReplaceHistory(history []*schema.Message) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.History = cloneMessageSlice(history)
|
||||
}
|
||||
|
||||
// AppendHistory 追加对话历史。
|
||||
//
|
||||
// 处理策略:
|
||||
// 1. 跳过 nil message,避免后续 prompt 拼装时出现空指针;
|
||||
// 2. 仅负责顺序追加,不做去重,不做裁剪;
|
||||
// 3. 历史裁剪策略属于后续 prompt / memory 层能力,此处先不下沉。
|
||||
func (c *ConversationContext) AppendHistory(messages ...*schema.Message) {
|
||||
if c == nil || len(messages) == 0 {
|
||||
return
|
||||
}
|
||||
for _, msg := range messages {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
c.History = append(c.History, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// HistorySnapshot 返回历史消息的浅拷贝切片。
|
||||
func (c *ConversationContext) HistorySnapshot() []*schema.Message {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneMessageSlice(c.History)
|
||||
}
|
||||
|
||||
// UpsertPinnedBlock 按 Key 写入或覆盖一段置顶上下文。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. Key 为空时直接忽略,因为后续无法做稳定覆盖;
|
||||
// 2. 若已存在同 Key block,则原位覆盖,保证"当前 plan / 当前步骤"这类上下文始终只有一份;
|
||||
// 3. 若不存在,则追加到末尾,至于渲染顺序由 prompt 层统一决定;
|
||||
// 4. 此处不自动裁剪旧内容,避免 model 层擅自丢信息。
|
||||
func (c *ConversationContext) UpsertPinnedBlock(block ContextBlock) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(block.Key)
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
block.Key = key
|
||||
block.Title = strings.TrimSpace(block.Title)
|
||||
block.Content = strings.TrimSpace(block.Content)
|
||||
|
||||
for i := range c.PinnedBlocks {
|
||||
if c.PinnedBlocks[i].Key == key {
|
||||
c.PinnedBlocks[i] = block
|
||||
return
|
||||
}
|
||||
}
|
||||
c.PinnedBlocks = append(c.PinnedBlocks, block)
|
||||
}
|
||||
|
||||
// RemovePinnedBlock 删除指定 Key 的置顶上下文。
|
||||
func (c *ConversationContext) RemovePinnedBlock(key string) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
for i := range c.PinnedBlocks {
|
||||
if c.PinnedBlocks[i].Key != key {
|
||||
continue
|
||||
}
|
||||
c.PinnedBlocks = append(c.PinnedBlocks[:i], c.PinnedBlocks[i+1:]...)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PinnedBlockByKey 按 Key 读取指定的置顶上下文。
|
||||
func (c *ConversationContext) PinnedBlockByKey(key string) (ContextBlock, bool) {
|
||||
if c == nil {
|
||||
return ContextBlock{}, false
|
||||
}
|
||||
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return ContextBlock{}, false
|
||||
}
|
||||
|
||||
for i := range c.PinnedBlocks {
|
||||
if c.PinnedBlocks[i].Key == key {
|
||||
return c.PinnedBlocks[i], true
|
||||
}
|
||||
}
|
||||
return ContextBlock{}, false
|
||||
}
|
||||
|
||||
// PinnedBlocksSnapshot 返回置顶上下文块的浅拷贝切片。
|
||||
func (c *ConversationContext) PinnedBlocksSnapshot() []ContextBlock {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]ContextBlock, len(c.PinnedBlocks))
|
||||
copy(result, c.PinnedBlocks)
|
||||
return result
|
||||
}
|
||||
|
||||
// SetToolSchemas 整体替换工具 schema 摘要。
|
||||
func (c *ConversationContext) SetToolSchemas(schemas []ToolSchemaContext) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.ToolSchemas = cloneToolSchemaSlice(schemas)
|
||||
}
|
||||
|
||||
// ToolSchemasSnapshot 返回工具 schema 摘要的浅拷贝切片。
|
||||
func (c *ConversationContext) ToolSchemasSnapshot() []ToolSchemaContext {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneToolSchemaSlice(c.ToolSchemas)
|
||||
}
|
||||
|
||||
func cloneMessageSlice(messages []*schema.Message) []*schema.Message {
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*schema.Message, len(messages))
|
||||
copy(result, messages)
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneToolSchemaSlice(schemas []ToolSchemaContext) []ToolSchemaContext {
|
||||
if len(schemas) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]ToolSchemaContext, len(schemas))
|
||||
copy(result, schemas)
|
||||
return result
|
||||
}
|
||||
489
backend/services/agent/model/execute_contract.go
Normal file
489
backend/services/agent/model/execute_contract.go
Normal file
@@ -0,0 +1,489 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ExecuteAction 表示 execute 阶段单轮决策的动作类型。
|
||||
//
|
||||
// 设计原则:
|
||||
// 1. LLM 只负责“申报本轮想做什么”,不直接推进状态;
|
||||
// 2. 后端只围绕这些有限动作做流程校验、证据校验、安全校验;
|
||||
// 3. 动作枚举保持收敛,避免 execute 节点后续再次长成“自由文本协议”。
|
||||
type ExecuteAction string
|
||||
|
||||
const (
|
||||
// ExecuteActionContinue 表示当前步骤尚未完成,需要继续本步骤的 ReAct 循环。
|
||||
ExecuteActionContinue ExecuteAction = "continue"
|
||||
|
||||
// ExecuteActionAskUser 表示当前步骤缺少外部信息,需要中断并追问用户。
|
||||
ExecuteActionAskUser ExecuteAction = "ask_user"
|
||||
|
||||
// ExecuteActionConfirm 表示当前步骤准备执行写操作,但必须先进入确认闸门。
|
||||
ExecuteActionConfirm ExecuteAction = "confirm"
|
||||
|
||||
// ExecuteActionNextPlan 表示当前步骤已完成,可以推进到下一个 plan 步骤。
|
||||
ExecuteActionNextPlan ExecuteAction = "next_plan"
|
||||
|
||||
// ExecuteActionDone 表示整个任务已完成,可以进入最终交付。
|
||||
ExecuteActionDone ExecuteAction = "done"
|
||||
|
||||
// ExecuteActionAbort 表示本轮流程应立即终止,并进入 deliver 做正式收口。
|
||||
ExecuteActionAbort ExecuteAction = "abort"
|
||||
)
|
||||
|
||||
// ExecuteDecision 是 execute prompt 单轮产出的统一决策结构。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. Speak 是这轮先对用户说的话,适合在真正调工具前流式吐给前端;
|
||||
// 2. Action 是模型申报的“下一步动作类型”;
|
||||
// 3. Reason 是给后端和日志看的简短解释,不直接等价于完成证明;
|
||||
// 4. ToolCall 只是“意图”,不代表工具已经真正执行成功。
|
||||
type ExecuteDecision struct {
|
||||
Speak string `json:"speak,omitempty"`
|
||||
Action ExecuteAction `json:"action"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
GoalCheck string `json:"goal_check,omitempty"`
|
||||
ToolCall *ToolCallIntent `json:"tool_call,omitempty"`
|
||||
Abort *AbortIntent `json:"abort,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON 兼容执行决策里几种模型高频跑偏但语义可恢复的写法。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把“空字符串占位字段”归一化成未填写,避免 json 反序列化阶段直接失败;
|
||||
// 2. 负责把 tool_call / abort 交给各自的兼容解析逻辑,尽量保留可恢复的信息;
|
||||
// 3. 不负责业务合法性校验;action 与字段互斥关系仍交给 Validate 判定。
|
||||
func (d *ExecuteDecision) UnmarshalJSON(data []byte) error {
|
||||
type rawExecuteDecision struct {
|
||||
Speak string `json:"speak,omitempty"`
|
||||
Action ExecuteAction `json:"action"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
GoalCheck json.RawMessage `json:"goal_check,omitempty"`
|
||||
ToolCall json.RawMessage `json:"tool_call,omitempty"`
|
||||
Abort json.RawMessage `json:"abort,omitempty"`
|
||||
}
|
||||
|
||||
var raw rawExecuteDecision
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
d.Speak = raw.Speak
|
||||
d.Action = raw.Action
|
||||
d.Reason = raw.Reason
|
||||
goalCheck, err := decodeGoalCheckText(raw.GoalCheck)
|
||||
if err != nil {
|
||||
return fmt.Errorf("goal_check 解析失败: %w", err)
|
||||
}
|
||||
d.GoalCheck = goalCheck
|
||||
|
||||
toolCall, err := decodeOptionalJSONObject[ToolCallIntent](raw.ToolCall)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tool_call 解析失败: %w", err)
|
||||
}
|
||||
d.ToolCall = toolCall
|
||||
|
||||
abortIntent, err := decodeOptionalJSONObject[AbortIntent](raw.Abort)
|
||||
if err != nil {
|
||||
return fmt.Errorf("abort 解析失败: %w", err)
|
||||
}
|
||||
d.Abort = abortIntent
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeGoalCheckText 兼容 goal_check 的字符串/对象写法,统一降级为字符串。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 字符串:直接使用,保持主协议不变;
|
||||
// 2. 对象:按 done_when/evidence 提取并拼接为单行证据文本;
|
||||
// 3. 数组或其他标量:尽量转成可读字符串,避免仅因格式漂移导致整轮失败。
|
||||
func decodeGoalCheckText(raw json.RawMessage) (string, error) {
|
||||
trimmed := strings.TrimSpace(string(raw))
|
||||
if trimmed == "" || trimmed == "null" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// 1. 标准写法:goal_check 为字符串。
|
||||
if strings.HasPrefix(trimmed, "\"") {
|
||||
var text string
|
||||
if err := json.Unmarshal(raw, &text); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(text), nil
|
||||
}
|
||||
|
||||
// 2. 兼容写法:goal_check 被模型写成对象。
|
||||
if strings.HasPrefix(trimmed, "{") {
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return compactGoalCheckObject(obj), nil
|
||||
}
|
||||
|
||||
// 3. 兜底:数组/标量场景,尽量保留可读信息。
|
||||
var generic any
|
||||
if err := json.Unmarshal(raw, &generic); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(formatGoalCheckValue(generic)), nil
|
||||
}
|
||||
|
||||
// compactGoalCheckObject 将对象型 goal_check 压缩为可读单行文本,优先提取 done_when/evidence。
|
||||
func compactGoalCheckObject(obj map[string]any) string {
|
||||
if len(obj) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
doneWhen := strings.TrimSpace(formatGoalCheckValue(obj["done_when"]))
|
||||
evidence := strings.TrimSpace(formatGoalCheckValue(obj["evidence"]))
|
||||
|
||||
parts := make([]string, 0, 2)
|
||||
if doneWhen != "" {
|
||||
parts = append(parts, "已满足 done_when:"+doneWhen)
|
||||
}
|
||||
if evidence != "" {
|
||||
parts = append(parts, "证据:"+evidence)
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
|
||||
// done_when/evidence 缺失时,按 key 排序拼接,保证日志稳定可读。
|
||||
keys := make([]string, 0, len(obj))
|
||||
for key := range obj {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
fallback := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
text := strings.TrimSpace(formatGoalCheckValue(obj[key]))
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
fallback = append(fallback, key+"="+text)
|
||||
}
|
||||
return strings.Join(fallback, ";")
|
||||
}
|
||||
|
||||
// formatGoalCheckValue 将任意值转成单行可读文本,用于 goal_check 压缩拼接。
|
||||
func formatGoalCheckValue(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
case bool:
|
||||
if typed {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case []any:
|
||||
parts := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
text := strings.TrimSpace(formatGoalCheckValue(item))
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, text)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(typed))
|
||||
for key := range typed {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
text := strings.TrimSpace(formatGoalCheckValue(typed[key]))
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, key+"="+text)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprintf("%v", typed))
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize 统一清洗 execute 决策中的字符串字段。
|
||||
func (d *ExecuteDecision) Normalize() {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
d.Speak = strings.TrimSpace(d.Speak)
|
||||
d.Action = ExecuteAction(strings.TrimSpace(string(d.Action)))
|
||||
d.Reason = strings.TrimSpace(d.Reason)
|
||||
d.GoalCheck = strings.TrimSpace(d.GoalCheck)
|
||||
if d.ToolCall != nil {
|
||||
d.ToolCall.Normalize()
|
||||
}
|
||||
if d.Abort != nil {
|
||||
d.Abort.Normalize()
|
||||
}
|
||||
}
|
||||
|
||||
// Validate 校验 execute 决策的最小合法性。
|
||||
//
|
||||
// 校验原则:
|
||||
// 1. 这里只校验“协议是否自洽”,不校验工具是否真实存在,也不校验当前步骤是否真的完成;
|
||||
// 2. 只允许少量动作与 tool_call 共存,避免后续 node 层收到含糊决策;
|
||||
// 3. 真正的三类最小校验应放在执行层,这里只做第一道轻量门禁。
|
||||
func (d *ExecuteDecision) Validate() error {
|
||||
if d == nil {
|
||||
return fmt.Errorf("execute decision 不能为空")
|
||||
}
|
||||
|
||||
d.Normalize()
|
||||
if d.Action == "" {
|
||||
return fmt.Errorf("execute decision.action 不能为空")
|
||||
}
|
||||
|
||||
switch d.Action {
|
||||
case ExecuteActionContinue:
|
||||
if d.Abort != nil {
|
||||
return fmt.Errorf("continue 动作不应携带 abort")
|
||||
}
|
||||
if d.ToolCall != nil {
|
||||
return d.ToolCall.Validate()
|
||||
}
|
||||
return nil
|
||||
case ExecuteActionAskUser:
|
||||
if d.ToolCall != nil {
|
||||
return fmt.Errorf("ask_user 动作不应携带 tool_call")
|
||||
}
|
||||
if d.Abort != nil {
|
||||
return fmt.Errorf("ask_user 动作不应携带 abort")
|
||||
}
|
||||
return nil
|
||||
case ExecuteActionConfirm:
|
||||
if d.ToolCall == nil {
|
||||
return fmt.Errorf("confirm 动作必须携带待确认的 tool_call")
|
||||
}
|
||||
if d.Abort != nil {
|
||||
return fmt.Errorf("confirm 动作不应同时携带 abort")
|
||||
}
|
||||
return d.ToolCall.Validate()
|
||||
case ExecuteActionNextPlan, ExecuteActionDone:
|
||||
if d.ToolCall != nil {
|
||||
return fmt.Errorf("%s 动作不应携带 tool_call", d.Action)
|
||||
}
|
||||
if d.Abort != nil {
|
||||
return fmt.Errorf("%s 动作不应携带 abort", d.Action)
|
||||
}
|
||||
return nil
|
||||
case ExecuteActionAbort:
|
||||
if d.ToolCall != nil {
|
||||
return fmt.Errorf("abort 动作不应携带 tool_call")
|
||||
}
|
||||
if d.Abort == nil {
|
||||
return fmt.Errorf("abort 动作必须携带 abort 字段")
|
||||
}
|
||||
return d.Abort.Validate()
|
||||
default:
|
||||
return fmt.Errorf("未知 execute action: %s", d.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// AbortIntent 表示 execute 阶段声明的正式终止意图。
|
||||
//
|
||||
// 说明:
|
||||
// 1. code 是稳定机器码,便于后续前端/埋点识别终止类型;
|
||||
// 2. user_message 是最终给用户看的收口文案;
|
||||
// 3. internal_reason 只用于日志排查,允许更技术化。
|
||||
type AbortIntent struct {
|
||||
Code string `json:"code,omitempty"`
|
||||
UserMessage string `json:"user_message"`
|
||||
InternalReason string `json:"internal_reason,omitempty"`
|
||||
}
|
||||
|
||||
// Normalize 清洗终止意图中的稳定字段。
|
||||
func (a *AbortIntent) Normalize() {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
a.Code = strings.TrimSpace(a.Code)
|
||||
a.UserMessage = strings.TrimSpace(a.UserMessage)
|
||||
a.InternalReason = strings.TrimSpace(a.InternalReason)
|
||||
}
|
||||
|
||||
// Validate 校验终止意图的最小可用性。
|
||||
func (a *AbortIntent) Validate() error {
|
||||
if a == nil {
|
||||
return fmt.Errorf("abort 不能为空")
|
||||
}
|
||||
a.Normalize()
|
||||
if a.UserMessage == "" {
|
||||
return fmt.Errorf("abort.user_message 不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToolCallIntent 表示 execute 阶段申报的工具调用意图。
|
||||
//
|
||||
// 设计目的:
|
||||
// 1. 这里只描述“模型想调用什么工具、传什么参数”,不代表调用已经发生;
|
||||
// 2. Arguments 暂时保留 map 结构,方便 prompt 输出原生 JSON 对象;
|
||||
// 3. 是否需要 confirm 不应由模型决定,后续应由工具注册表或后端策略判定。
|
||||
type ToolCallIntent struct {
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]any `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON 兼容 tool_call 里“arguments / parameters”两种高频字段名。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 优先使用标准字段 arguments,保持当前正式协议不变;
|
||||
// 2. 仅当 arguments 缺失时,回退复用 parameters,兼容模型历史习惯;
|
||||
// 3. 不负责校验参数是否满足具体工具 schema,后续仍由工具层负责。
|
||||
func (t *ToolCallIntent) UnmarshalJSON(data []byte) error {
|
||||
type rawToolCallIntent struct {
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]any `json:"arguments,omitempty"`
|
||||
Parameters map[string]any `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
var raw rawToolCallIntent
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.Name = raw.Name
|
||||
t.Arguments = raw.Arguments
|
||||
if len(t.Arguments) == 0 && len(raw.Parameters) > 0 {
|
||||
t.Arguments = raw.Parameters
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Normalize 清洗工具调用意图中的稳定字段。
|
||||
func (t *ToolCallIntent) Normalize() {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
t.Name = strings.TrimSpace(t.Name)
|
||||
}
|
||||
|
||||
// Validate 校验工具调用意图的最小合法性。
|
||||
func (t *ToolCallIntent) Validate() error {
|
||||
if t == nil {
|
||||
return fmt.Errorf("tool_call 不能为空")
|
||||
}
|
||||
t.Normalize()
|
||||
if t.Name == "" {
|
||||
return fmt.Errorf("tool_call.name 不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeOptionalJSONObject 统一兼容“可选对象字段被模型写成空字符串”的情况。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 字段缺失、null、空字符串都视为“未填写”,返回 nil;
|
||||
// 2. 只有在确实出现对象内容时,才继续反序列化为目标结构;
|
||||
// 3. 若模型传入了非空字符串等不可恢复内容,显式报错,避免把脏数据静默吞掉。
|
||||
func decodeOptionalJSONObject[T any](raw json.RawMessage) (*T, error) {
|
||||
trimmed := strings.TrimSpace(string(raw))
|
||||
if trimmed == "" || trimmed == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(trimmed, "\"") {
|
||||
var text string
|
||||
if err := json.Unmarshal(raw, &text); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("期望对象,实际收到非空字符串")
|
||||
}
|
||||
|
||||
var out T
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ExecuteEvidenceSource 表示“当前步骤完成证明”来自哪里。
|
||||
type ExecuteEvidenceSource string
|
||||
|
||||
const (
|
||||
// ExecuteEvidenceSourceToolObservation 表示来自读工具或分析工具的真实 observation。
|
||||
ExecuteEvidenceSourceToolObservation ExecuteEvidenceSource = "tool_observation"
|
||||
|
||||
// ExecuteEvidenceSourceWriteReceipt 表示来自写工具成功执行后的回执。
|
||||
ExecuteEvidenceSourceWriteReceipt ExecuteEvidenceSource = "write_receipt"
|
||||
|
||||
// ExecuteEvidenceSourceUserReply 表示来自用户补充回答的外部事实。
|
||||
ExecuteEvidenceSourceUserReply ExecuteEvidenceSource = "user_reply"
|
||||
)
|
||||
|
||||
// ExecuteEvidenceReceipt 表示“一条可被后端认可的最小事实证据”。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. StepIndex 用来绑定这条证据属于哪个 plan 步骤,避免旧 observation 污染新步骤;
|
||||
// 2. Source / Name / Success 描述“这条证据是怎么来的、是否真的发生了”;
|
||||
// 3. Summary 只用于日志、调试和交付串联,不替代原始 observation 本身;
|
||||
// 4. 这里不做语义推理,只负责记录事实。
|
||||
type ExecuteEvidenceReceipt struct {
|
||||
StepIndex int `json:"step_index"`
|
||||
Source ExecuteEvidenceSource `json:"source"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ArgumentsDigest string `json:"arguments_digest,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
// Normalize 清洗证据回执中的稳定字段。
|
||||
func (r *ExecuteEvidenceReceipt) Normalize() {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.Source = ExecuteEvidenceSource(strings.TrimSpace(string(r.Source)))
|
||||
r.Name = strings.TrimSpace(r.Name)
|
||||
r.ArgumentsDigest = strings.TrimSpace(r.ArgumentsDigest)
|
||||
r.Summary = strings.TrimSpace(r.Summary)
|
||||
}
|
||||
|
||||
// Validate 校验证据回执是否具备最小可用信息。
|
||||
func (r *ExecuteEvidenceReceipt) Validate() error {
|
||||
if r == nil {
|
||||
return fmt.Errorf("evidence receipt 不能为空")
|
||||
}
|
||||
|
||||
r.Normalize()
|
||||
if r.StepIndex < 0 {
|
||||
return fmt.Errorf("evidence receipt.step_index 不能小于 0")
|
||||
}
|
||||
switch r.Source {
|
||||
case ExecuteEvidenceSourceToolObservation, ExecuteEvidenceSourceWriteReceipt, ExecuteEvidenceSourceUserReply:
|
||||
default:
|
||||
return fmt.Errorf("未知 evidence source: %s", r.Source)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecuteValidationResult 保存 execute 单轮的三类最小校验结果。
|
||||
//
|
||||
// 三类校验语义:
|
||||
// 1. FlowPassed:当前动作在流程上是否合法,例如 done 是否允许直接发生;
|
||||
// 2. EvidencePassed:当前动作是否有最小事实证据支撑;
|
||||
// 3. SafetyPassed:当前动作是否触发了安全兜底,例如超轮次、重复空转、待确认未完成。
|
||||
type ExecuteValidationResult struct {
|
||||
FlowPassed bool `json:"flow_passed"`
|
||||
FlowReason string `json:"flow_reason,omitempty"`
|
||||
EvidencePassed bool `json:"evidence_passed"`
|
||||
EvidenceReason string `json:"evidence_reason,omitempty"`
|
||||
SafetyPassed bool `json:"safety_passed"`
|
||||
SafetyReason string `json:"safety_reason,omitempty"`
|
||||
}
|
||||
337
backend/services/agent/model/graph_run_state.go
Normal file
337
backend/services/agent/model/graph_run_state.go
Normal file
@@ -0,0 +1,337 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
agenttools "github.com/LoveLosita/smartflow/backend/services/agent/tools"
|
||||
schedule "github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// AgentGraphRequest 描述一次 agent graph 运行的请求级输入。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只放"当前这次请求"天然携带的轻量数据,例如用户本轮输入;
|
||||
// 2. 不负责承载可持久化流程状态,流程状态仍归 AgentRuntimeState;
|
||||
// 3. 不负责承载 LLM / emitter / store 等依赖,这些统一放进 AgentGraphDeps。
|
||||
type AgentGraphRequest struct {
|
||||
UserInput string
|
||||
ConfirmAction string // "accept" / "reject" / "",仅 confirm 恢复场景由前端传入
|
||||
// ResumeInteractionID 用于校验“本次恢复请求”是否命中了当前 pending 交互,避免旧卡片误恢复。
|
||||
ResumeInteractionID string
|
||||
AlwaysExecute bool // true 时写工具跳过确认闸门直接执行,适合前端已展示预览、用户无需逐步确认的场景
|
||||
}
|
||||
|
||||
// Normalize 统一清洗请求级输入中的字符串字段。
|
||||
func (r *AgentGraphRequest) Normalize() {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.UserInput = strings.TrimSpace(r.UserInput)
|
||||
r.ConfirmAction = strings.TrimSpace(r.ConfirmAction)
|
||||
r.ResumeInteractionID = strings.TrimSpace(r.ResumeInteractionID)
|
||||
}
|
||||
|
||||
// RoughBuildPlacement 是粗排算法返回的单条放置结果。
|
||||
// 字段使用 DB 坐标系(week/dayOfWeek/section),由 RoughBuild 节点转换为 ScheduleState 的 day_index。
|
||||
type RoughBuildPlacement struct {
|
||||
TaskItemID int
|
||||
Week int
|
||||
DayOfWeek int
|
||||
SectionFrom int
|
||||
SectionTo int
|
||||
}
|
||||
|
||||
// RoughBuildFunc 是粗排算法的依赖注入签名。
|
||||
// 由 service 层封装 HybridScheduleWithPlanMulti 后注入,agent 层不直接依赖外层 model。
|
||||
type RoughBuildFunc func(ctx context.Context, userID int, taskClassIDs []int) ([]RoughBuildPlacement, error)
|
||||
|
||||
// WriteSchedulePreviewFunc 是排程预览写入的依赖注入签名。
|
||||
// 由 service 层封装 cacheDAO 后注入,execute/deliver 节点可按需调用:
|
||||
// 1. execute 写工具后可实时刷新,保障前端及时看到最新调整;
|
||||
// 2. deliver 结束时再做最终覆盖写,保障收口状态一致。
|
||||
type WriteSchedulePreviewFunc func(ctx context.Context, state *schedule.ScheduleState, userID int, conversationID string, taskClassIDs []int) error
|
||||
|
||||
// PersistVisibleMessageFunc 是 agent 主循环逐条持久化可见消息的回调签名。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只处理真正对用户可见的 assistant speak,不处理工具结果或内部纠错提示;
|
||||
// 2. 由节点在 AppendHistory 之后主动调用,让上层同步把这条消息写入 Redis + MySQL;
|
||||
// 3. 执行方可以做无损降级(例如 Redis 写失败只记日志),但应返回 error 便于上层记录。
|
||||
type PersistVisibleMessageFunc func(ctx context.Context, state *CommonState, msg *schema.Message) error
|
||||
|
||||
// AgentGraphDeps 描述 graph/node 层运行时真正依赖的可插拔能力。
|
||||
//
|
||||
// 设计目的:
|
||||
// 1. 让 graph 不再只拿到"裸状态",而是能拿到上下文、模型和输出能力;
|
||||
// 2. Chat/Plan/Execute/Deliver 允许分别挂不同 client,但也允许先复用同一个 client;
|
||||
// 3. ChunkEmitter 统一承接阶段提示、正文、工具事件、确认请求等 SSE 输出。
|
||||
type AgentGraphDeps struct {
|
||||
ChatClient *llmservice.Client
|
||||
PlanClient *llmservice.Client
|
||||
ExecuteClient *llmservice.Client
|
||||
DeliverClient *llmservice.Client
|
||||
ChunkEmitter *agentstream.ChunkEmitter
|
||||
StateStore AgentStateStore
|
||||
ToolRegistry *agenttools.ToolRegistry
|
||||
ScheduleProvider ScheduleStateProvider // 按 DAO 注入,Execute 节点按需加载 ScheduleState
|
||||
CompactionStore CompactionStore // 按 DAO 注入,用于 Execute 上下文压缩持久化
|
||||
RoughBuildFunc RoughBuildFunc // 按 Service 注入,粗排算法入口
|
||||
WriteSchedulePreview WriteSchedulePreviewFunc // 按 Service 注入,排程预览写入入口
|
||||
|
||||
// thinking 开关:由 config.yaml 的 agent.thinking 段注入,各节点按需读取。
|
||||
ThinkingPlan bool
|
||||
ThinkingExecute bool
|
||||
ThinkingDeliver bool
|
||||
|
||||
// 记忆预取管线:由 service 层启动的后台检索 goroutine 写入。
|
||||
// channel 携带已渲染的文本内容(非原始 ItemDTO),节点直接写入 pinned block。
|
||||
MemoryFuture chan string // buffered(1),携带 renderMemoryPinnedContentByMode 的输出
|
||||
MemoryConsumed bool // 保证 channel 只读一次,后续 Execute ReAct 循环跳过等待
|
||||
|
||||
// PersistVisibleMessage 按 Service 注入,agent 每个节点产出的可见 speak
|
||||
// 都会在 AppendHistory 之后立刻调用这个回调,把消息同步落到 Redis + MySQL。
|
||||
PersistVisibleMessage PersistVisibleMessageFunc
|
||||
|
||||
// QuickTaskDeps 快捷任务节点的直接依赖,绕过 ToolRegistry 走轻量路径。
|
||||
QuickTaskDeps QuickTaskDeps
|
||||
}
|
||||
|
||||
// QuickTaskDeps 描述快捷任务节点所需的服务层依赖。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. QuickTask 节点直接调这些函数,不经过 ToolRegistry,不走 ReAct 循环;
|
||||
// 2. 这里只保留“创建任务 / 查询任务”两类轻量能力,避免再回退到已下线的孤立工具链。
|
||||
type QuickTaskDeps struct {
|
||||
// CreateTask 创建一条四象限任务,返回 task_id。
|
||||
CreateTask func(userID int, title string, priorityGroup int, estimatedSections int, deadlineAt *time.Time, urgencyThresholdAt *time.Time) (taskID int, err error)
|
||||
// QueryTasks 按条件查询用户任务列表。
|
||||
QueryTasks func(ctx context.Context, userID int, params TaskQueryParams) ([]TaskQueryResult, error)
|
||||
}
|
||||
|
||||
// --- 记忆 pinned block 常量(供 agent/sv 和 node 层共享) ---
|
||||
|
||||
const (
|
||||
// MemoryContextBlockKey 记忆上下文在 ConversationContext PinnedBlock 中的唯一 key。
|
||||
MemoryContextBlockKey = "memory_context"
|
||||
// MemoryContextBlockTitle 记忆上下文 pinned block 的标题,用于 prompt 渲染。
|
||||
MemoryContextBlockTitle = "相关记忆"
|
||||
// MemoryFreshTimeout 是 Execute/Plan 节点等待后台记忆检索完成的最大时长。
|
||||
MemoryFreshTimeout = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// EnsureChunkEmitter 保证 graph 运行时始终有一个可用的 chunk 发射器。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 依赖为空时回退到 Noop emitter,避免骨架期因为没接前端而到处判空;
|
||||
// 2. 这里只兜底"能安全调用",不负责填充真实 request_id / model_name;
|
||||
// 3. 后续 service 层一旦接上真实 emitter,会自然覆盖这里的空实现。
|
||||
func (d *AgentGraphDeps) EnsureChunkEmitter() *agentstream.ChunkEmitter {
|
||||
if d == nil {
|
||||
return agentstream.NewChunkEmitter(agentstream.NoopPayloadEmitter(), "", "", 0)
|
||||
}
|
||||
if d.ChunkEmitter == nil {
|
||||
d.ChunkEmitter = agentstream.NewChunkEmitter(agentstream.NoopPayloadEmitter(), "", "", 0)
|
||||
}
|
||||
return d.ChunkEmitter
|
||||
}
|
||||
|
||||
// ResolveChatClient 返回 chat 阶段可用的模型客户端。
|
||||
func (d *AgentGraphDeps) ResolveChatClient() *llmservice.Client {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
return d.ChatClient
|
||||
}
|
||||
|
||||
// ResolvePlanClient 返回 planning 阶段可用的模型客户端。
|
||||
//
|
||||
// 兜底策略:
|
||||
// 1. 优先使用显式注入的 PlanClient;
|
||||
// 2. 若未单独注入,则回退到 ChatClient;
|
||||
// 3. 这样在骨架期可先用一套 client 跑通,再按需拆分 strategist / worker。
|
||||
func (d *AgentGraphDeps) ResolvePlanClient() *llmservice.Client {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
if d.PlanClient != nil {
|
||||
return d.PlanClient
|
||||
}
|
||||
return d.ChatClient
|
||||
}
|
||||
|
||||
// ResolveExecuteClient 返回 execute 阶段可用的模型客户端。
|
||||
func (d *AgentGraphDeps) ResolveExecuteClient() *llmservice.Client {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
if d.ExecuteClient != nil {
|
||||
return d.ExecuteClient
|
||||
}
|
||||
if d.PlanClient != nil {
|
||||
return d.PlanClient
|
||||
}
|
||||
return d.ChatClient
|
||||
}
|
||||
|
||||
// ResolveDeliverClient 返回 deliver 阶段可用的模型客户端。
|
||||
func (d *AgentGraphDeps) ResolveDeliverClient() *llmservice.Client {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
if d.DeliverClient != nil {
|
||||
return d.DeliverClient
|
||||
}
|
||||
if d.ExecuteClient != nil {
|
||||
return d.ExecuteClient
|
||||
}
|
||||
if d.PlanClient != nil {
|
||||
return d.PlanClient
|
||||
}
|
||||
return d.ChatClient
|
||||
}
|
||||
|
||||
// AgentGraphRunInput 是执行 agent 通用 graph 所需的完整入口参数。
|
||||
//
|
||||
// 字段说明:
|
||||
// 1. RuntimeState:可持久化流程状态与 pending interaction;
|
||||
// 2. ConversationContext:本轮喂给模型的上下文材料;
|
||||
// 3. Request:当前这次请求的轻量输入;
|
||||
// 4. Deps:graph/node 层真正依赖的可插拔能力。
|
||||
type AgentGraphRunInput struct {
|
||||
RuntimeState *AgentRuntimeState
|
||||
ConversationContext *ConversationContext
|
||||
ScheduleState *schedule.ScheduleState
|
||||
OriginalScheduleState *schedule.ScheduleState
|
||||
Request AgentGraphRequest
|
||||
Deps AgentGraphDeps
|
||||
}
|
||||
|
||||
// AgentGraphState 是 graph 内部真正流转的运行态容器。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把"流程状态 + 对话上下文 + 请求输入 + 运行依赖"收口到同一个对象;
|
||||
// 2. 负责给 graph 分支和 node 提供最小必要的兜底访问方法;
|
||||
// 3. 不负责持久化,不负责真正业务执行。
|
||||
type AgentGraphState struct {
|
||||
RuntimeState *AgentRuntimeState
|
||||
ConversationContext *ConversationContext
|
||||
Request AgentGraphRequest
|
||||
Deps AgentGraphDeps
|
||||
ScheduleState *schedule.ScheduleState // 工具操作的内存数据源,Execute 节点按需加载
|
||||
OriginalScheduleState *schedule.ScheduleState // 首次加载时的原始快照,供 diff 用
|
||||
}
|
||||
|
||||
// NewAgentGraphState 把入口参数整理成 graph 内部状态。
|
||||
func NewAgentGraphState(input AgentGraphRunInput) *AgentGraphState {
|
||||
st := &AgentGraphState{
|
||||
RuntimeState: input.RuntimeState,
|
||||
ConversationContext: input.ConversationContext,
|
||||
Request: input.Request,
|
||||
Deps: input.Deps,
|
||||
ScheduleState: input.ScheduleState,
|
||||
OriginalScheduleState: input.OriginalScheduleState,
|
||||
}
|
||||
st.Request.Normalize()
|
||||
st.EnsureRuntimeState()
|
||||
st.EnsureConversationContext()
|
||||
st.Deps.EnsureChunkEmitter()
|
||||
return st
|
||||
}
|
||||
|
||||
// EnsureRuntimeState 保证 graph 内部始终持有一份可用的运行态。
|
||||
func (s *AgentGraphState) EnsureRuntimeState() *AgentRuntimeState {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if s.RuntimeState == nil {
|
||||
s.RuntimeState = NewAgentRuntimeState(nil)
|
||||
}
|
||||
s.RuntimeState.EnsureCommonState()
|
||||
return s.RuntimeState
|
||||
}
|
||||
|
||||
// EnsureFlowState 返回可持久化的主流程状态。
|
||||
func (s *AgentGraphState) EnsureFlowState() *CommonState {
|
||||
runtimeState := s.EnsureRuntimeState()
|
||||
if runtimeState == nil {
|
||||
return nil
|
||||
}
|
||||
return runtimeState.EnsureCommonState()
|
||||
}
|
||||
|
||||
// EnsureConversationContext 保证 graph 内部始终持有一份可用的会话上下文。
|
||||
func (s *AgentGraphState) EnsureConversationContext() *ConversationContext {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if s.ConversationContext == nil {
|
||||
s.ConversationContext = NewConversationContext("")
|
||||
}
|
||||
return s.ConversationContext
|
||||
}
|
||||
|
||||
// EnsureChunkEmitter 返回 graph 可安全调用的 chunk 发射器。
|
||||
func (s *AgentGraphState) EnsureChunkEmitter() *agentstream.ChunkEmitter {
|
||||
if s == nil {
|
||||
return agentstream.NewChunkEmitter(agentstream.NoopPayloadEmitter(), "", "", 0)
|
||||
}
|
||||
return s.Deps.EnsureChunkEmitter()
|
||||
}
|
||||
|
||||
// ResolveToolRegistry 返回可用的工具注册表。
|
||||
func (s *AgentGraphState) ResolveToolRegistry() *agenttools.ToolRegistry {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.Deps.ToolRegistry
|
||||
}
|
||||
|
||||
// EnsureScheduleState 确保 ScheduleState 已加载。
|
||||
// 首次调用时通过 ScheduleProvider 从 DB 加载,后续复用内存中的 state。
|
||||
func (s *AgentGraphState) EnsureScheduleState(ctx context.Context) (*schedule.ScheduleState, error) {
|
||||
if s == nil {
|
||||
return nil, nil
|
||||
}
|
||||
flowState := s.EnsureFlowState()
|
||||
if s.ScheduleState != nil {
|
||||
if s.OriginalScheduleState == nil {
|
||||
// 1. 兼容老快照:历史 Redis 快照里可能还没带 original_state。
|
||||
// 2. 当前阶段虽然已经不落库,但后续若重新接回 diff 链,仍需要稳定的原始快照。
|
||||
// 3. 因此这里在"已恢复出 ScheduleState、但缺 original"时补一份克隆兜底。
|
||||
s.OriginalScheduleState = s.ScheduleState.Clone()
|
||||
}
|
||||
schedule.FilterScheduleStateForTaskClassScope(s.ScheduleState, flowState.TaskClassIDs)
|
||||
schedule.FilterScheduleStateForTaskClassScope(s.OriginalScheduleState, flowState.TaskClassIDs)
|
||||
return s.ScheduleState, nil
|
||||
}
|
||||
if s.Deps.ScheduleProvider == nil {
|
||||
return nil, nil
|
||||
}
|
||||
userID := flowState.UserID
|
||||
var (
|
||||
state *schedule.ScheduleState
|
||||
err error
|
||||
)
|
||||
// 1. 若 provider 支持按 task_class_ids 精确加载,则优先走 scoped 入口。
|
||||
// 2. 这样可以让 DayMapping 与粗排算法使用同一批任务类窗口,避免"全量任务类脏日期污染本轮窗口"。
|
||||
// 3. 若当前实现尚未支持 scoped 加载,则回退到旧入口,并继续复用后面的 scope 裁剪。
|
||||
if scopedProvider, ok := s.Deps.ScheduleProvider.(ScopedScheduleStateProvider); ok && len(flowState.TaskClassIDs) > 0 {
|
||||
state, err = scopedProvider.LoadScheduleStateForTaskClasses(ctx, userID, flowState.TaskClassIDs)
|
||||
} else {
|
||||
state, err = s.Deps.ScheduleProvider.LoadScheduleState(ctx, userID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.ScheduleState = state
|
||||
// 保存原始快照,供后续 diff 使用。
|
||||
s.OriginalScheduleState = state.Clone()
|
||||
schedule.FilterScheduleStateForTaskClassScope(s.ScheduleState, flowState.TaskClassIDs)
|
||||
schedule.FilterScheduleStateForTaskClassScope(s.OriginalScheduleState, flowState.TaskClassIDs)
|
||||
return state, nil
|
||||
}
|
||||
252
backend/services/agent/model/pending_interaction.go
Normal file
252
backend/services/agent/model/pending_interaction.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package model
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
// PhaseChatting 表示当前请求只需正常聊天,不进入 plan / execute 主链路。
|
||||
PhaseChatting Phase = "chatting"
|
||||
|
||||
// PhaseInterrupted 表示本轮执行被"待用户交互"显式打断,当前连接应结束并等待恢复。
|
||||
PhaseInterrupted Phase = "interrupted"
|
||||
)
|
||||
|
||||
const PendingInteractionSnapshotVersion = 1
|
||||
|
||||
const (
|
||||
// PendingMetaAskUserSpeakStreamed 表示 ask_user 文本已在上游节点流式推送过。
|
||||
// interrupt 节点据此决定是否跳过二次正文推送,避免前端出现重复气泡。
|
||||
PendingMetaAskUserSpeakStreamed = "ask_user_speak_streamed"
|
||||
// PendingMetaAskUserHistoryAppended 表示 ask_user 文本已在上游写入过 history。
|
||||
// interrupt 节点据此避免二次追加历史,防止上下文重复。
|
||||
PendingMetaAskUserHistoryAppended = "ask_user_history_appended"
|
||||
)
|
||||
|
||||
// PendingInteractionType 表示当前挂起交互的类型。
|
||||
type PendingInteractionType string
|
||||
|
||||
const (
|
||||
PendingInteractionTypeAskUser PendingInteractionType = "ask_user"
|
||||
PendingInteractionTypeConfirm PendingInteractionType = "confirm"
|
||||
PendingInteractionTypeConnectionLost PendingInteractionType = "connection_lost"
|
||||
)
|
||||
|
||||
// PendingInteractionStatus 表示挂起交互的生命周期状态。
|
||||
type PendingInteractionStatus string
|
||||
|
||||
const (
|
||||
PendingInteractionStatusOpen PendingInteractionStatus = "open"
|
||||
PendingInteractionStatusResolved PendingInteractionStatus = "resolved"
|
||||
PendingInteractionStatusCanceled PendingInteractionStatus = "canceled"
|
||||
)
|
||||
|
||||
// PendingToolCallSnapshot 保存"待确认工具调用"的最小快照。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责保存真正落库 / 落缓存恢复执行所需的最小信息;
|
||||
// 2. ArgsJSON 约定存已经序列化好的参数快照,避免此处反向依赖具体 tool 参数结构;
|
||||
// 3. 不负责工具执行,不负责幂等校验,不负责回滚。
|
||||
type PendingToolCallSnapshot struct {
|
||||
ToolName string `json:"tool_name"`
|
||||
ArgsJSON string `json:"args_json"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
// PendingInteraction 保存"本轮需要中断并等待用户后续动作"的交互快照。
|
||||
//
|
||||
// 设计目的:
|
||||
// 1. ask_user 与 confirm 都不是业务 tool,而是流程级中断,所以单独建模;
|
||||
// 2. ResumeNode / ResumePhase / ResumeStep 用来记录恢复点,避免用户回答后整条链路从头乱跑;
|
||||
// 3. 该结构设计成可被 Redis + MySQL 直接存储的快照骨架,后续只需要补序列化与持久化接线。
|
||||
//
|
||||
// TODO(agent/api): 后续由"用户追问回复接口 / 确认回调接口"读取这份快照并恢复运行。
|
||||
type PendingInteraction struct {
|
||||
Version int `json:"version"`
|
||||
InteractionID string `json:"interaction_id"`
|
||||
Type PendingInteractionType `json:"type"`
|
||||
Status PendingInteractionStatus `json:"status"`
|
||||
DisplayText string `json:"display_text"`
|
||||
ResumeNode string `json:"resume_node"`
|
||||
ResumePhase Phase `json:"resume_phase"`
|
||||
ResumeStep int `json:"resume_step"`
|
||||
PendingTool *PendingToolCallSnapshot `json:"pending_tool,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// AgentRuntimeState 是 graph 运行时真正流转的状态容器。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. CommonState 继续只负责主流程控制;
|
||||
// 2. PendingInteraction 负责承载"需要中断后恢复"的交互快照;
|
||||
// 3. 这样既不污染 CommonState 的职责,又能让 graph 在一次入参里拿到完整运行态。
|
||||
type AgentRuntimeState struct {
|
||||
*CommonState `json:"common_state"`
|
||||
// PendingInteraction 承载挂起交互的持久化快照。
|
||||
PendingInteraction *PendingInteraction `json:"pending_interaction,omitempty"`
|
||||
// PendingConfirmTool 是 Execute → Confirm 之间传递待确认工具信息的临时邮箱。
|
||||
// Execute 节点写入,Confirm 节点读出并清空,不参与持久化。
|
||||
PendingConfirmTool *PendingToolCallSnapshot `json:"-"`
|
||||
}
|
||||
|
||||
// NewAgentRuntimeState 创建 graph 运行态。
|
||||
func NewAgentRuntimeState(state *CommonState) *AgentRuntimeState {
|
||||
rt := &AgentRuntimeState{CommonState: state}
|
||||
rt.EnsureCommonState()
|
||||
return rt
|
||||
}
|
||||
|
||||
// EnsureCommonState 保证运行态里始终有一份可用的流程状态。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 若 CommonState 为空,则补一份最小默认值,避免 graph / node 层空指针;
|
||||
// 2. 若 Phase 尚未设置,则默认回到 planning,保持当前主链路的保守起点;
|
||||
// 3. 若 MaxRounds 未设置,则回填默认值,避免编译后运行时无上限循环。
|
||||
func (s *AgentRuntimeState) EnsureCommonState() *CommonState {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if s.CommonState == nil {
|
||||
s.CommonState = &CommonState{}
|
||||
}
|
||||
if s.CommonState.Phase == "" {
|
||||
s.CommonState.Phase = PhasePlanning
|
||||
}
|
||||
if s.CommonState.MaxRounds <= 0 {
|
||||
s.CommonState.MaxRounds = DefaultMaxRounds
|
||||
}
|
||||
return s.CommonState
|
||||
}
|
||||
|
||||
// HasPendingInteraction 判断当前是否存在待恢复交互。
|
||||
func (s *AgentRuntimeState) HasPendingInteraction() bool {
|
||||
if s == nil || s.PendingInteraction == nil {
|
||||
return false
|
||||
}
|
||||
return s.PendingInteraction.Status == PendingInteractionStatusOpen
|
||||
}
|
||||
|
||||
// PendingInteractionType 返回当前挂起交互类型。
|
||||
func (s *AgentRuntimeState) PendingInteractionType() PendingInteractionType {
|
||||
if !s.HasPendingInteraction() {
|
||||
return ""
|
||||
}
|
||||
return s.PendingInteraction.Type
|
||||
}
|
||||
|
||||
// OpenAskUserInteraction 打开一个"向用户追问"的中断快照。
|
||||
func (s *AgentRuntimeState) OpenAskUserInteraction(interactionID, question, resumeNode string) {
|
||||
s.openPendingInteraction(
|
||||
PendingInteractionTypeAskUser,
|
||||
interactionID,
|
||||
question,
|
||||
resumeNode,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
// OpenConfirmInteraction 打开一个"写操作待确认"的中断快照。
|
||||
func (s *AgentRuntimeState) OpenConfirmInteraction(interactionID, confirmText, resumeNode string, pendingTool *PendingToolCallSnapshot) {
|
||||
s.openPendingInteraction(
|
||||
PendingInteractionTypeConfirm,
|
||||
interactionID,
|
||||
confirmText,
|
||||
resumeNode,
|
||||
pendingTool,
|
||||
)
|
||||
}
|
||||
|
||||
// ResumeFromPending 从挂起交互恢复主流程。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 仅当存在 open 状态的 pending interaction 时才执行恢复;
|
||||
// 2. 恢复时回写之前快照下来的 phase / step,确保继续跑的是原任务位置而不是新分支;
|
||||
// 3. 恢复成功后清空挂起快照,避免同一份 pending 被重复消费。
|
||||
func (s *AgentRuntimeState) ResumeFromPending() bool {
|
||||
if !s.HasPendingInteraction() {
|
||||
return false
|
||||
}
|
||||
|
||||
flowState := s.EnsureCommonState()
|
||||
pending := s.PendingInteraction
|
||||
flowState.Phase = pending.ResumePhase
|
||||
flowState.CurrentStep = pending.ResumeStep
|
||||
pending.Status = PendingInteractionStatusResolved
|
||||
s.PendingInteraction = nil
|
||||
return true
|
||||
}
|
||||
|
||||
// ClearPendingInteraction 直接清空挂起交互。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 仅负责粗暴清空快照;
|
||||
// 2. 不自动恢复 phase / step,避免误把"取消交互"与"恢复执行"混为一谈;
|
||||
// 3. 若需要恢复流程,应优先使用 ResumeFromPending。
|
||||
func (s *AgentRuntimeState) ClearPendingInteraction() {
|
||||
if s == nil || s.PendingInteraction == nil {
|
||||
return
|
||||
}
|
||||
s.PendingInteraction.Status = PendingInteractionStatusCanceled
|
||||
s.PendingInteraction = nil
|
||||
}
|
||||
|
||||
// SetPendingInteractionMetadata 为当前 open 状态的 pending interaction 写入元信息。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 仅对当前挂起交互打运行态标记,不参与业务语义判断;
|
||||
// 2. 若当前没有 pending interaction,则静默跳过;
|
||||
// 3. metadata 仅用于节点间协作(如避免 ask_user 重复推送)。
|
||||
func (s *AgentRuntimeState) SetPendingInteractionMetadata(key string, value any) {
|
||||
if s == nil || s.PendingInteraction == nil || s.PendingInteraction.Status != PendingInteractionStatusOpen {
|
||||
return
|
||||
}
|
||||
trimmedKey := strings.TrimSpace(key)
|
||||
if trimmedKey == "" {
|
||||
return
|
||||
}
|
||||
if s.PendingInteraction.Metadata == nil {
|
||||
s.PendingInteraction.Metadata = make(map[string]any)
|
||||
}
|
||||
s.PendingInteraction.Metadata[trimmedKey] = value
|
||||
}
|
||||
|
||||
func (s *AgentRuntimeState) openPendingInteraction(
|
||||
interactionType PendingInteractionType,
|
||||
interactionID string,
|
||||
displayText string,
|
||||
resumeNode string,
|
||||
pendingTool *PendingToolCallSnapshot,
|
||||
) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
flowState := s.EnsureCommonState()
|
||||
resumePhase := flowState.Phase
|
||||
if resumePhase == "" {
|
||||
resumePhase = PhasePlanning
|
||||
}
|
||||
|
||||
s.PendingInteraction = &PendingInteraction{
|
||||
Version: PendingInteractionSnapshotVersion,
|
||||
InteractionID: strings.TrimSpace(interactionID),
|
||||
Type: interactionType,
|
||||
Status: PendingInteractionStatusOpen,
|
||||
DisplayText: strings.TrimSpace(displayText),
|
||||
ResumeNode: strings.TrimSpace(resumeNode),
|
||||
ResumePhase: resumePhase,
|
||||
ResumeStep: flowState.CurrentStep,
|
||||
PendingTool: clonePendingToolCallSnapshot(pendingTool),
|
||||
}
|
||||
|
||||
// 1. 一旦进入 pending 状态,当前连接上的 graph 应立即停止向后执行。
|
||||
// 2. 这里先统一把 Phase 置为 interrupted,后续恢复时再按快照写回原阶段。
|
||||
// 3. 这样分支函数只需要判断 HasPendingInteraction(),无需猜测"当前 phase 是否仍可信"。
|
||||
flowState.Phase = PhaseInterrupted
|
||||
}
|
||||
|
||||
func clonePendingToolCallSnapshot(snapshot *PendingToolCallSnapshot) *PendingToolCallSnapshot {
|
||||
if snapshot == nil {
|
||||
return nil
|
||||
}
|
||||
copied := *snapshot
|
||||
return &copied
|
||||
}
|
||||
245
backend/services/agent/model/plan_contract.go
Normal file
245
backend/services/agent/model/plan_contract.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PlanComplexity 表示规划阶段评估的任务复杂度。
|
||||
type PlanComplexity string
|
||||
|
||||
const (
|
||||
// PlanComplexitySimple 表示简单明确的操作,步骤之间无复杂依赖。
|
||||
PlanComplexitySimple PlanComplexity = "simple"
|
||||
|
||||
// PlanComplexityModerate 表示多步操作,需要一定推理但不涉及深度分析。
|
||||
PlanComplexityModerate PlanComplexity = "moderate"
|
||||
|
||||
// PlanComplexityComplex 表示需要深度推理、多方案比较或复杂依赖关系的任务。
|
||||
PlanComplexityComplex PlanComplexity = "complex"
|
||||
)
|
||||
|
||||
// PlanAction 表示规划阶段单轮决策的动作类型。
|
||||
//
|
||||
// 设计原则:
|
||||
// 1. 规划阶段只关心“继续规划 / 追问用户 / 规划完成”这三类动作;
|
||||
// 2. 这里先不把工具调用塞进 contract,避免过早把 plan loop 复杂化;
|
||||
// 3. 规划层产出的是“自然语言计划”,不是执行层的工具动作。
|
||||
type PlanAction string
|
||||
|
||||
const (
|
||||
// PlanActionContinue 表示当前信息已足够,继续规划下一轮。
|
||||
PlanActionContinue PlanAction = "continue"
|
||||
|
||||
// PlanActionAskUser 表示当前规划缺少关键信息,需要中断并追问用户。
|
||||
PlanActionAskUser PlanAction = "ask_user"
|
||||
|
||||
// PlanActionDone 表示规划已经完成,可以进入 confirm 或下一阶段。
|
||||
PlanActionDone PlanAction = "plan_done"
|
||||
)
|
||||
|
||||
// PlanDecision 是 plan prompt 单轮产出的统一决策结构。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. Speak 是本轮先对用户说的话;若 action=ask_user,通常这里会承载要追问的问题;
|
||||
// 2. Action 是规划阶段的下一步动作类型;
|
||||
// 3. Reason 是给后端和日志看的简短解释;
|
||||
// 4. PlanSteps 只在 plan_done 时要求返回,表示本轮最终确认下来的完整自然语言计划;
|
||||
// 5. NeedsRoughBuild 为 true 时,Confirm 后自动触发粗排节点,不需要 LLM 在 plan_steps 里手动描述放置步骤;
|
||||
// 6. TaskClassIDs 是本次粗排涉及的任务类 ID 列表,与 CommonState.TaskClassIDs 保持一致。
|
||||
type PlanDecision struct {
|
||||
Speak string `json:"speak,omitempty"`
|
||||
Action PlanAction `json:"action"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Complexity PlanComplexity `json:"complexity"`
|
||||
PlanSteps []PlanStep `json:"plan_steps,omitempty"`
|
||||
NeedsRoughBuild bool `json:"needs_rough_build,omitempty"`
|
||||
TaskClassIDs []int `json:"task_class_ids,omitempty"`
|
||||
ContextHook *ContextHook `json:"context_hook,omitempty"`
|
||||
}
|
||||
|
||||
// ContextHook 表示 plan 阶段给 execute 阶段的上下文注入建议。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 仅承载“建议激活哪个 domain/packs”,不负责真正执行 context_tools_add/remove;
|
||||
// 2. domain 仅允许 schedule/taskclass,packs 仅允许 schedule 的可选包;
|
||||
// 3. 该结构会在 execute 首轮被消费一次,消费后由后端清空。
|
||||
type ContextHook struct {
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Packs []string `json:"packs,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Normalize 统一清洗规划决策中的字符串字段。
|
||||
func (d *PlanDecision) Normalize() {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
d.Speak = strings.TrimSpace(d.Speak)
|
||||
d.Action = PlanAction(strings.TrimSpace(string(d.Action)))
|
||||
d.Reason = strings.TrimSpace(d.Reason)
|
||||
d.Complexity = PlanComplexity(strings.TrimSpace(string(d.Complexity)))
|
||||
for i := range d.PlanSteps {
|
||||
d.PlanSteps[i].Normalize()
|
||||
}
|
||||
if d.ContextHook != nil {
|
||||
d.ContextHook.Normalize()
|
||||
}
|
||||
}
|
||||
|
||||
// Validate 校验规划决策的最小合法性。
|
||||
//
|
||||
// 校验原则:
|
||||
// 1. 这里只校验“协议是否自洽”,不校验规划内容是否聪明、是否足够好;
|
||||
// 2. 只有 plan_done 允许返回完整 plan_steps;
|
||||
// 3. 真正的规划质量判断仍留给后续 node 层和用户确认环节。
|
||||
func (d *PlanDecision) Validate() error {
|
||||
if d == nil {
|
||||
return fmt.Errorf("plan decision 不能为空")
|
||||
}
|
||||
|
||||
d.Normalize()
|
||||
if d.Action == "" {
|
||||
return fmt.Errorf("plan decision.action 不能为空")
|
||||
}
|
||||
|
||||
// 复杂度兜底:未填写时默认 moderate,不因此拒绝整个决策。
|
||||
switch d.Complexity {
|
||||
case PlanComplexitySimple, PlanComplexityModerate, PlanComplexityComplex:
|
||||
// ok
|
||||
case "":
|
||||
d.Complexity = PlanComplexityModerate
|
||||
default:
|
||||
return fmt.Errorf("未知 complexity: %s", d.Complexity)
|
||||
}
|
||||
|
||||
switch d.Action {
|
||||
case PlanActionContinue, PlanActionAskUser:
|
||||
if len(d.PlanSteps) > 0 {
|
||||
return fmt.Errorf("%s 动作不应携带 plan_steps", d.Action)
|
||||
}
|
||||
if d.ContextHook != nil {
|
||||
return fmt.Errorf("%s 动作不应携带 context_hook", d.Action)
|
||||
}
|
||||
return nil
|
||||
case PlanActionDone:
|
||||
if len(d.PlanSteps) == 0 {
|
||||
return fmt.Errorf("plan_done 动作必须携带完整 plan_steps")
|
||||
}
|
||||
for i := range d.PlanSteps {
|
||||
if err := d.PlanSteps[i].Validate(); err != nil {
|
||||
return fmt.Errorf("plan_steps[%d] 非法: %w", i, err)
|
||||
}
|
||||
}
|
||||
if d.ContextHook != nil {
|
||||
if err := d.ContextHook.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("未知 plan action: %s", d.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// PlanStep 表示规划阶段产出的一条自然语言步骤。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. Content 是步骤正文,后续可直接落到 CommonState.PlanSteps;
|
||||
// 2. DoneWhen 是可选的完成判定描述,用来给 execute 阶段提供最小退出条件;
|
||||
// 3. 这里仍然保持“自然语言优先”,不把 plan step 过度结构化。
|
||||
type PlanStep struct {
|
||||
Content string `json:"content"`
|
||||
DoneWhen string `json:"done_when,omitempty"`
|
||||
}
|
||||
|
||||
// Normalize 统一清洗 plan step 中的字符串字段。
|
||||
func (s *PlanStep) Normalize() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.Content = strings.TrimSpace(s.Content)
|
||||
s.DoneWhen = strings.TrimSpace(s.DoneWhen)
|
||||
}
|
||||
|
||||
// Validate 校验单条 plan step 的最小合法性。
|
||||
func (s *PlanStep) Validate() error {
|
||||
if s == nil {
|
||||
return fmt.Errorf("plan step 不能为空")
|
||||
}
|
||||
s.Normalize()
|
||||
if s.Content == "" {
|
||||
return fmt.Errorf("plan step.content 不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Normalize 统一清洗 context hook 字段。
|
||||
func (h *ContextHook) Normalize() {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.Domain = normalizeContextHookDomain(h.Domain)
|
||||
h.Reason = strings.TrimSpace(h.Reason)
|
||||
h.Packs = normalizeContextHookPacks(h.Domain, h.Packs)
|
||||
}
|
||||
|
||||
// Validate 校验 context hook 最小合法性。
|
||||
func (h *ContextHook) Validate() error {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
h.Normalize()
|
||||
if h.Domain == "" {
|
||||
return fmt.Errorf("context_hook.domain 非法,仅支持 schedule/taskclass")
|
||||
}
|
||||
if h.Domain == "taskclass" && len(h.Packs) > 0 {
|
||||
return fmt.Errorf("context_hook.taskclass 暂不支持 packs")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeContextHookDomain(domain string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(domain)) {
|
||||
case "schedule":
|
||||
return "schedule"
|
||||
case "taskclass":
|
||||
return "taskclass"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeContextHookPacks(domain string, packs []string) []string {
|
||||
if domain != "schedule" || len(packs) == 0 {
|
||||
return nil
|
||||
}
|
||||
allowed := map[string]struct{}{
|
||||
"queue": {},
|
||||
"mutation": {},
|
||||
"analyze": {},
|
||||
"detail_read": {},
|
||||
"deep_analyze": {},
|
||||
"web": {},
|
||||
}
|
||||
seen := make(map[string]struct{}, len(packs))
|
||||
result := make([]string, 0, len(packs))
|
||||
for _, raw := range packs {
|
||||
pack := strings.ToLower(strings.TrimSpace(raw))
|
||||
if pack == "" || pack == "core" {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[pack]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[pack]; exists {
|
||||
continue
|
||||
}
|
||||
seen[pack] = struct{}{}
|
||||
result = append(result, pack)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
89
backend/services/agent/model/state_store.go
Normal file
89
backend/services/agent/model/state_store.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
schedule "github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
)
|
||||
|
||||
// AgentStateSnapshot 是需要持久化的 agent 运行态最小快照。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 只保存恢复执行所需的 RuntimeState 和 ConversationContext;
|
||||
// 2. 不保存 Request(每轮请求级,天然不跨连接);
|
||||
// 3. 不保存 Deps(依赖注入,每次由 Service 层重建);
|
||||
// 4. 不保存 ToolSchemas(每次请求由 Service 层重新注入)。
|
||||
type AgentStateSnapshot struct {
|
||||
RuntimeState *AgentRuntimeState `json:"runtime_state"`
|
||||
ConversationContext *ConversationContext `json:"conversation_context"`
|
||||
ScheduleState *schedule.ScheduleState `json:"schedule_state,omitempty"`
|
||||
OriginalScheduleState *schedule.ScheduleState `json:"original_schedule_state,omitempty"`
|
||||
}
|
||||
|
||||
// AgentStateStore 定义 agent 状态持久化的最小接口。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责"存 / 取 / 删"三个原子操作;
|
||||
// 2. 不负责序列化细节(由实现层决定 JSON / protobuf);
|
||||
// 3. 不负责业务级状态校验,校验仍在 node / graph 层完成。
|
||||
//
|
||||
// 实现层:
|
||||
// 1. dao/cache.go 上的 CacheDAO 隐式实现该接口(Go duck typing);
|
||||
// 2. agent 包不直接 import dao,由 Service 层在组装 Deps 时注入。
|
||||
type AgentStateStore interface {
|
||||
// Save 序列化并保存一份 agent 状态快照。
|
||||
//
|
||||
// 语义:
|
||||
// 1. 同一 conversationID 被覆盖写入,保证 Redis 里始终只有最新快照;
|
||||
// 2. 实现层应设 TTL,避免已完成的任务快照永不清理。
|
||||
Save(ctx context.Context, conversationID string, snapshot *AgentStateSnapshot) error
|
||||
|
||||
// Load 读取并反序列化 agent 状态快照。
|
||||
//
|
||||
// 返回值语义:
|
||||
// 1. (snapshot, true, nil):命中快照,正常返回;
|
||||
// 2. (nil, false, nil):未命中,不是错误,调用方应走新建对话路径;
|
||||
// 3. (nil, false, error):真正的存储层错误。
|
||||
Load(ctx context.Context, conversationID string) (*AgentStateSnapshot, bool, error)
|
||||
|
||||
// Delete 删除指定会话的 agent 状态快照。
|
||||
//
|
||||
// 语义:
|
||||
// 1. 删除是幂等的,key 不存在也视为成功;
|
||||
// 2. 典型调用时机:Deliver 节点任务完成后清理。
|
||||
Delete(ctx context.Context, conversationID string) error
|
||||
}
|
||||
|
||||
// ScheduleStateProvider 定义加载 ScheduleState 的接口。
|
||||
// 由 DAO 层或 Service 层实现,注入到 AgentGraphDeps 中。
|
||||
// 使用接口而非具体 DAO 类型,避免 model → dao 的循环依赖。
|
||||
type ScheduleStateProvider interface {
|
||||
LoadScheduleState(ctx context.Context, userID int) (*schedule.ScheduleState, error)
|
||||
// LoadTaskClassMetas 只加载指定任务类的约束元数据,供 Plan 节点提前消费。
|
||||
LoadTaskClassMetas(ctx context.Context, userID int, taskClassIDs []int) ([]schedule.TaskClassMeta, error)
|
||||
}
|
||||
|
||||
// ScopedScheduleStateProvider 定义“按本轮任务类范围加载 ScheduleState”的可选增强接口。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 负责:在 Execute / RoughBuild 首次加载状态时,把 DayMapping、TaskClasses 与 pending 任务限定在本轮 task_class_ids 相关窗口;
|
||||
// 2. 不负责:改变既有 ScheduleStateProvider 的基础能力,老实现仍可只实现 LoadScheduleState;
|
||||
// 3. 兜底策略:若调用方拿到的 provider 不实现该接口,则回退到全量 LoadScheduleState,再走工具层 scope 裁剪。
|
||||
type ScopedScheduleStateProvider interface {
|
||||
LoadScheduleStateForTaskClasses(ctx context.Context, userID int, taskClassIDs []int) (*schedule.ScheduleState, error)
|
||||
}
|
||||
|
||||
// CompactionStore 定义上下文压缩的持久化接口。
|
||||
// 由 Service 层实现(组合 DAO + Redis Cache),注入到各阶段 NodeInput。
|
||||
type CompactionStore interface {
|
||||
LoadCompaction(ctx context.Context, userID int, chatID string) (summary string, watermark int, err error)
|
||||
SaveCompaction(ctx context.Context, userID int, chatID string, summary string, watermark int) error
|
||||
SaveContextTokenStats(ctx context.Context, userID int, chatID string, statsJSON string) error
|
||||
|
||||
// LoadStageCompaction 按 stageKey 加载压缩摘要和水位线。
|
||||
// stageKey 区分不同节点(如 "execute"/"plan"/"chat"/"deliver"),
|
||||
// 使各节点可以独立维护各自的压缩状态,互不覆盖。
|
||||
LoadStageCompaction(ctx context.Context, userID int, chatID string, stageKey string) (summary string, watermark int, err error)
|
||||
// SaveStageCompaction 按 stageKey 保存压缩摘要和水位线。
|
||||
SaveStageCompaction(ctx context.Context, userID int, chatID string, stageKey string, summary string, watermark int) error
|
||||
}
|
||||
36
backend/services/agent/model/taskquery_contract.go
Normal file
36
backend/services/agent/model/taskquery_contract.go
Normal file
@@ -0,0 +1,36 @@
|
||||
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. 这里只保留展示所需字段,避免把底层任务模型直接暴露给 agent 节点;
|
||||
// 2. 结果既可用于 quick_task 节点文本回复,也可供 service 装配其他轻量输出;
|
||||
// 3. 不负责序列化策略和文案渲染。
|
||||
type TaskQueryResult struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PriorityGroup int `json:"priority_group"`
|
||||
EstimatedSections int `json:"estimated_sections"`
|
||||
PriorityLabel string `json:"priority_label"`
|
||||
IsCompleted bool `json:"is_completed"`
|
||||
DeadlineAt string `json:"deadline_at,omitempty"`
|
||||
}
|
||||
27
backend/services/agent/model/taskquery_types.go
Normal file
27
backend/services/agent/model/taskquery_types.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// TaskQueryRequest 是任务查询工具的请求参数。
|
||||
type TaskQueryRequest struct {
|
||||
UserID int
|
||||
Quadrant *int
|
||||
SortBy string
|
||||
Order string
|
||||
Limit int
|
||||
IncludeCompleted bool
|
||||
Keyword string
|
||||
DeadlineBefore *time.Time
|
||||
DeadlineAfter *time.Time
|
||||
}
|
||||
|
||||
// TaskQueryTaskRecord 是任务查询工具返回的单条任务记录。
|
||||
type TaskQueryTaskRecord struct {
|
||||
ID int
|
||||
Title string
|
||||
PriorityGroup int
|
||||
EstimatedSections int
|
||||
IsCompleted bool
|
||||
DeadlineAt *time.Time
|
||||
UrgencyThresholdAt *time.Time
|
||||
}
|
||||
362
backend/services/agent/node/agent_nodes.go
Normal file
362
backend/services/agent/node/agent_nodes.go
Normal file
@@ -0,0 +1,362 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agenttools "github.com/LoveLosita/smartflow/backend/services/agent/tools"
|
||||
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
)
|
||||
|
||||
// AgentNodes 负责把 graph 层的节点调用统一转成 node 层真正的执行入口。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只做参数转发、依赖注入和状态落盘,不承载业务决策。
|
||||
// 2. 各节点真正的执行逻辑仍在对应的 RunXXXNode 内。
|
||||
// 3. 节点成功后统一保存快照,方便断线恢复。
|
||||
type AgentNodes struct{}
|
||||
|
||||
// NewAgentNodes 创建通用节点容器。
|
||||
func NewAgentNodes() *AgentNodes {
|
||||
return &AgentNodes{}
|
||||
}
|
||||
|
||||
// Chat 负责把 graph 的 chat 节点请求转给 RunChatNode。
|
||||
func (n *AgentNodes) Chat(ctx context.Context, st *agentmodel.AgentGraphState) (*agentmodel.AgentGraphState, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("chat node: state is nil")
|
||||
}
|
||||
|
||||
// 1. Chat 阶段只负责路由与纯对话,不需要看到工具目录,避免能力细节干扰判断。
|
||||
st.EnsureConversationContext().SetToolSchemas(nil)
|
||||
|
||||
if err := RunChatNode(ctx, ChatNodeInput{
|
||||
RuntimeState: st.EnsureRuntimeState(),
|
||||
ConversationContext: st.EnsureConversationContext(),
|
||||
UserInput: st.Request.UserInput,
|
||||
ConfirmAction: st.Request.ConfirmAction,
|
||||
ResumeInteractionID: st.Request.ResumeInteractionID,
|
||||
Client: st.Deps.ResolveChatClient(),
|
||||
ChunkEmitter: st.EnsureChunkEmitter(),
|
||||
CompactionStore: st.Deps.CompactionStore,
|
||||
PersistVisibleMessage: st.Deps.PersistVisibleMessage,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
saveAgentState(ctx, st)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Confirm 负责把 graph 的 confirm 节点请求转给 RunConfirmNode。
|
||||
func (n *AgentNodes) Confirm(ctx context.Context, st *agentmodel.AgentGraphState) (*agentmodel.AgentGraphState, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("confirm node: state is nil")
|
||||
}
|
||||
|
||||
if err := RunConfirmNode(ctx, ConfirmNodeInput{
|
||||
RuntimeState: st.EnsureRuntimeState(),
|
||||
ConversationContext: st.EnsureConversationContext(),
|
||||
ChunkEmitter: st.EnsureChunkEmitter(),
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
saveAgentState(ctx, st)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Plan 负责把 graph 的 plan 节点请求转给 RunPlanNode。
|
||||
func (n *AgentNodes) Plan(ctx context.Context, st *agentmodel.AgentGraphState) (*agentmodel.AgentGraphState, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("plan node: state is nil")
|
||||
}
|
||||
|
||||
// 等待后端记忆检索完成,再把最新结果注入上下文。
|
||||
ensureFreshMemory(st)
|
||||
|
||||
if err := RunPlanNode(ctx, PlanNodeInput{
|
||||
RuntimeState: st.EnsureRuntimeState(),
|
||||
ConversationContext: st.EnsureConversationContext(),
|
||||
UserInput: st.Request.UserInput,
|
||||
Client: st.Deps.ResolvePlanClient(),
|
||||
ChunkEmitter: st.EnsureChunkEmitter(),
|
||||
ResumeNode: "plan",
|
||||
AlwaysExecute: st.Request.AlwaysExecute,
|
||||
ThinkingEnabled: st.Deps.ThinkingPlan,
|
||||
CompactionStore: st.Deps.CompactionStore,
|
||||
PersistVisibleMessage: st.Deps.PersistVisibleMessage,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
saveAgentState(ctx, st)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// RoughBuild 负责把 graph 的 rough_build 节点请求转给 RunRoughBuildNode。
|
||||
func (n *AgentNodes) RoughBuild(ctx context.Context, st *agentmodel.AgentGraphState) (*agentmodel.AgentGraphState, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("rough_build node: state is nil")
|
||||
}
|
||||
|
||||
if err := RunRoughBuildNode(ctx, st); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
saveAgentState(ctx, st)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Interrupt 负责把 graph 的 interrupt 节点请求转给 RunInterruptNode。
|
||||
func (n *AgentNodes) Interrupt(ctx context.Context, st *agentmodel.AgentGraphState) (*agentmodel.AgentGraphState, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("interrupt node: state is nil")
|
||||
}
|
||||
|
||||
if err := RunInterruptNode(ctx, InterruptNodeInput{
|
||||
RuntimeState: st.EnsureRuntimeState(),
|
||||
ConversationContext: st.EnsureConversationContext(),
|
||||
ChunkEmitter: st.EnsureChunkEmitter(),
|
||||
PersistVisibleMessage: st.Deps.PersistVisibleMessage,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Execute 负责把 graph 的 execute 节点请求转给 RunExecuteNode。
|
||||
func (n *AgentNodes) Execute(ctx context.Context, st *agentmodel.AgentGraphState) (*agentmodel.AgentGraphState, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("execute node: state is nil")
|
||||
}
|
||||
|
||||
// 1. 首次进入时按需加载日程状态,后续轮次复用内存状态。
|
||||
var scheduleState *schedule.ScheduleState
|
||||
if ss, loadErr := st.EnsureScheduleState(ctx); loadErr != nil {
|
||||
return nil, fmt.Errorf("execute node: 加载日程状态失败: %w", loadErr)
|
||||
} else if ss != nil {
|
||||
scheduleState = ss
|
||||
}
|
||||
|
||||
// 2. 把工具 schema 注入上下文,供 LLM 看到真实工具边界。
|
||||
if st.Deps.ToolRegistry != nil {
|
||||
activeDomain := ""
|
||||
var activePacks []string
|
||||
if flowState := st.EnsureFlowState(); flowState != nil {
|
||||
activeDomain, activePacks = resolveEffectiveExecuteToolDomain(flowState)
|
||||
}
|
||||
schemas := st.Deps.ToolRegistry.SchemasForActiveDomain(activeDomain, activePacks)
|
||||
if flowState := st.EnsureFlowState(); flowState != nil && flowState.ActiveOptimizeOnly {
|
||||
schemas = agenttools.FilterSchemasForActiveOptimize(schemas)
|
||||
}
|
||||
toolSchemas := make([]agentmodel.ToolSchemaContext, len(schemas))
|
||||
for i, s := range schemas {
|
||||
toolSchemas[i] = agentmodel.ToolSchemaContext{
|
||||
Name: s.Name,
|
||||
Desc: s.Desc,
|
||||
SchemaText: s.SchemaText,
|
||||
}
|
||||
}
|
||||
st.EnsureConversationContext().SetToolSchemas(toolSchemas)
|
||||
}
|
||||
|
||||
// 3. 等待后端记忆检索结果,再把最新结果注入上下文。
|
||||
ensureFreshMemory(st)
|
||||
|
||||
if err := RunExecuteNode(ctx, ExecuteNodeInput{
|
||||
RuntimeState: st.EnsureRuntimeState(),
|
||||
ConversationContext: st.EnsureConversationContext(),
|
||||
UserInput: st.Request.UserInput,
|
||||
Client: st.Deps.ResolveExecuteClient(),
|
||||
ChunkEmitter: st.EnsureChunkEmitter(),
|
||||
ResumeNode: "execute",
|
||||
ToolRegistry: st.Deps.ToolRegistry,
|
||||
ScheduleState: scheduleState,
|
||||
CompactionStore: st.Deps.CompactionStore,
|
||||
WriteSchedulePreview: st.Deps.WriteSchedulePreview,
|
||||
OriginalScheduleState: st.OriginalScheduleState,
|
||||
AlwaysExecute: st.Request.AlwaysExecute,
|
||||
ThinkingEnabled: st.Deps.ThinkingExecute,
|
||||
PersistVisibleMessage: st.Deps.PersistVisibleMessage,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
saveAgentState(ctx, st)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// QuickTask 负责把 graph 的 quick_task 节点请求转给 RunQuickTaskNode。
|
||||
func (n *AgentNodes) QuickTask(ctx context.Context, st *agentmodel.AgentGraphState) (*agentmodel.AgentGraphState, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("quick_task node: state is nil")
|
||||
}
|
||||
|
||||
// QuickTask 不需要工具目录,直接复用 ChatClient。
|
||||
st.EnsureConversationContext().SetToolSchemas(nil)
|
||||
|
||||
if err := RunQuickTaskNode(ctx, QuickTaskNodeInput{
|
||||
RuntimeState: st.EnsureRuntimeState(),
|
||||
ConversationContext: st.EnsureConversationContext(),
|
||||
UserInput: st.Request.UserInput,
|
||||
Client: st.Deps.ResolveChatClient(),
|
||||
ChunkEmitter: st.EnsureChunkEmitter(),
|
||||
QuickTaskDeps: st.Deps.QuickTaskDeps,
|
||||
PersistVisibleMessage: st.Deps.PersistVisibleMessage,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
saveAgentState(ctx, st)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Deliver 负责把 graph 的 deliver 节点请求转给 RunDeliverNode。
|
||||
func (n *AgentNodes) Deliver(ctx context.Context, st *agentmodel.AgentGraphState) (*agentmodel.AgentGraphState, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("deliver node: state is nil")
|
||||
}
|
||||
|
||||
// 1. Deliver 只做最终收口总结,不需要工具目录,避免无关能力信息污染总结。
|
||||
st.EnsureConversationContext().SetToolSchemas(nil)
|
||||
|
||||
if err := RunDeliverNode(ctx, DeliverNodeInput{
|
||||
RuntimeState: st.EnsureRuntimeState(),
|
||||
ConversationContext: st.EnsureConversationContext(),
|
||||
Client: st.Deps.ResolveDeliverClient(),
|
||||
ChunkEmitter: st.EnsureChunkEmitter(),
|
||||
ThinkingEnabled: st.Deps.ThinkingDeliver,
|
||||
CompactionStore: st.Deps.CompactionStore,
|
||||
PersistVisibleMessage: st.Deps.PersistVisibleMessage,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 只有真正完成时才写入排程预览,避免中间态污染前端展示。
|
||||
if st.Deps.WriteSchedulePreview != nil && st.ScheduleState != nil {
|
||||
flowState := st.EnsureFlowState()
|
||||
if flowState != nil && flowState.IsCompleted() {
|
||||
if err := st.Deps.WriteSchedulePreview(ctx, st.ScheduleState, flowState.UserID, flowState.ConversationID, flowState.TaskClassIDs); err != nil {
|
||||
log.Printf("[WARN] deliver: 写入排程预览缓存失败 chat=%s: %v", flowState.ConversationID, err)
|
||||
}
|
||||
} else if flowState != nil {
|
||||
log.Printf("[DEBUG] deliver: skip schedule preview chat=%s terminal_status=%s", flowState.ConversationID, flowState.TerminalStatus())
|
||||
}
|
||||
}
|
||||
|
||||
saveAgentState(ctx, st)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// ensureFreshMemory 等待后端记忆检索完成,并把最新结果写入 ConversationContext。
|
||||
//
|
||||
// 1. 只在首次调用时等待 channel,后续调用直接跳过。
|
||||
// 2. 超时后保留原有上下文,不额外覆盖。
|
||||
// 3. 记忆为空时也不做额外写入,避免污染 prompt。
|
||||
func ensureFreshMemory(st *agentmodel.AgentGraphState) {
|
||||
if st == nil || st.Deps.MemoryConsumed || st.Deps.MemoryFuture == nil {
|
||||
return
|
||||
}
|
||||
st.Deps.MemoryConsumed = true
|
||||
|
||||
select {
|
||||
case content := <-st.Deps.MemoryFuture:
|
||||
if strings.TrimSpace(content) != "" {
|
||||
st.EnsureConversationContext().UpsertPinnedBlock(agentmodel.ContextBlock{
|
||||
Key: agentmodel.MemoryContextBlockKey,
|
||||
Title: agentmodel.MemoryContextBlockTitle,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
case <-time.After(agentmodel.MemoryFreshTimeout):
|
||||
// 超时后保留原有上下文即可。
|
||||
}
|
||||
}
|
||||
|
||||
// saveAgentState 在节点成功执行后保存运行快照。
|
||||
func saveAgentState(ctx context.Context, st *agentmodel.AgentGraphState) {
|
||||
if st == nil {
|
||||
return
|
||||
}
|
||||
store := st.Deps.StateStore
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
|
||||
runtimeState := st.EnsureRuntimeState()
|
||||
if runtimeState == nil {
|
||||
return
|
||||
}
|
||||
|
||||
flowState := runtimeState.EnsureCommonState()
|
||||
if flowState == nil || flowState.ConversationID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
snapshot := &agentmodel.AgentStateSnapshot{
|
||||
RuntimeState: runtimeState,
|
||||
ConversationContext: st.EnsureConversationContext(),
|
||||
ScheduleState: st.ScheduleState.Clone(),
|
||||
OriginalScheduleState: st.OriginalScheduleState.Clone(),
|
||||
}
|
||||
|
||||
_ = store.Save(ctx, flowState.ConversationID, snapshot)
|
||||
}
|
||||
|
||||
// deleteAgentState 在任务完成后删除运行快照。
|
||||
func deleteAgentState(ctx context.Context, st *agentmodel.AgentGraphState) {
|
||||
if st == nil {
|
||||
return
|
||||
}
|
||||
store := st.Deps.StateStore
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
|
||||
runtimeState := st.EnsureRuntimeState()
|
||||
if runtimeState == nil {
|
||||
return
|
||||
}
|
||||
|
||||
flowState := runtimeState.EnsureCommonState()
|
||||
if flowState == nil || flowState.ConversationID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
_ = store.Delete(ctx, flowState.ConversationID)
|
||||
}
|
||||
|
||||
// resolveEffectiveExecuteToolDomain 计算“本轮 execute 真正应看到”的工具域快照。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 优先读取 PendingContextHook,让首轮 execute 的 schema 注入与即将生效的规则包保持一致;
|
||||
// 2. 只做只读推导,不消费 PendingContextHook,真正的状态更新仍由 RunExecuteNode 统一处理;
|
||||
// 3. hook 非法或为空时,回退到已持久化的 ActiveToolDomain/ActiveToolPacks,保持历史链路兼容。
|
||||
func resolveEffectiveExecuteToolDomain(flowState *agentmodel.CommonState) (string, []string) {
|
||||
if flowState == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// 1. 若 plan / rough_build 已写入待生效 hook,则首轮 execute 必须优先按它推导工具域,
|
||||
// 否则 prompt 里的规则包和注入的工具 schema 会错位,模型第一轮看不到该用的工具。
|
||||
if hook := flowState.PendingContextHook; hook != nil {
|
||||
domain := agenttools.NormalizeToolDomain(hook.Domain)
|
||||
if domain != "" {
|
||||
return domain, agenttools.ResolveEffectiveToolPacks(domain, hook.Packs)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. hook 不可用时回退到当前已激活域,保持老链路与恢复链路的行为不变。
|
||||
domain := agenttools.NormalizeToolDomain(flowState.ActiveToolDomain)
|
||||
if domain == "" {
|
||||
return "", nil
|
||||
}
|
||||
return domain, agenttools.ResolveEffectiveToolPacks(domain, flowState.ActiveToolPacks)
|
||||
}
|
||||
908
backend/services/agent/node/chat.go
Normal file
908
backend/services/agent/node/chat.go
Normal file
@@ -0,0 +1,908 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"github.com/google/uuid"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentprompt "github.com/LoveLosita/smartflow/backend/services/agent/prompt"
|
||||
agentrouter "github.com/LoveLosita/smartflow/backend/services/agent/router"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
)
|
||||
|
||||
const (
|
||||
chatStageName = "chat"
|
||||
chatStatusBlockID = "chat.status"
|
||||
chatSpeakBlockID = "chat.speak"
|
||||
// chatHistoryKindKey 用于在 history 中打运行态标记,供 prompt 层做上下文分层。
|
||||
chatHistoryKindKey = "newagent_history_kind"
|
||||
// chatHistoryKindExecuteLoopClosed 表示"上一轮 execute loop 已正常收口"。
|
||||
// prompt 侧会据此把旧 loop 归档到 msg1,而不是继续占用 msg2 窗口。
|
||||
chatHistoryKindExecuteLoopClosed = "execute_loop_closed"
|
||||
)
|
||||
|
||||
type reorderPreference int
|
||||
|
||||
const (
|
||||
reorderUnknown reorderPreference = iota
|
||||
reorderAllow
|
||||
reorderDisallow
|
||||
)
|
||||
|
||||
// ChatNodeInput 描述聊天节点单轮运行所需的最小依赖。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只承载"本轮 chat"需要的输入,不负责持久化;
|
||||
// 2. RuntimeState 提供 pending interaction 与流程状态;
|
||||
// 3. ConversationContext 提供历史对话;
|
||||
// 4. ConfirmAction 仅在 confirm 恢复场景下由前端传入 "accept" / "reject"。
|
||||
type ChatNodeInput struct {
|
||||
RuntimeState *agentmodel.AgentRuntimeState
|
||||
ConversationContext *agentmodel.ConversationContext
|
||||
UserInput string
|
||||
ConfirmAction string
|
||||
ResumeInteractionID string
|
||||
Client *llmservice.Client
|
||||
ChunkEmitter *agentstream.ChunkEmitter
|
||||
CompactionStore agentmodel.CompactionStore // 上下文压缩持久化
|
||||
PersistVisibleMessage agentmodel.PersistVisibleMessageFunc
|
||||
}
|
||||
|
||||
// RunChatNode 执行一轮聊天节点逻辑。
|
||||
//
|
||||
// 核心职责:
|
||||
// 1. 恢复判定:有 pending interaction 则处理恢复;
|
||||
// 2. 路由分流:无 pending 时,调 LLM 判断复杂度并路由;
|
||||
// 3. direct_reply:简单任务,直接输出回复 → END;
|
||||
// 4. execute:中等任务,推 Execute ReAct;
|
||||
// 5. deep_answer:复杂问答,原地开 thinking 深度回答 → END;
|
||||
// 6. plan:复杂规划,推 Plan 节点。
|
||||
func RunChatNode(ctx context.Context, input ChatNodeInput) error {
|
||||
runtimeState, conversationContext, emitter, err := prepareChatNodeInput(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1. 有 pending interaction → 纯状态传递,处理恢复。
|
||||
if runtimeState.HasPendingInteraction() {
|
||||
return handleChatResume(input, runtimeState, emitter)
|
||||
}
|
||||
|
||||
// 2. 无 pending → 路由决策(一次快速 LLM 调用,不开 thinking)。
|
||||
flowState := runtimeState.EnsureCommonState()
|
||||
if !runtimeState.HasPendingInteraction() && flowState.Phase == agentmodel.PhaseDone {
|
||||
terminalBefore := flowState.TerminalStatus()
|
||||
roundBefore := flowState.RoundUsed
|
||||
// 1. 只有"正常完成(completed)"才打 loop 收口标记:
|
||||
// 1.1 这样下一轮进入 execute 时,msg2 会只保留"当前活跃循环"窗口;
|
||||
// 1.2 异常收口(exhausted/aborted)不打标记,允许后续"继续"时沿用上一轮 loop 轨迹。
|
||||
if terminalBefore == agentmodel.FlowTerminalStatusCompleted {
|
||||
appendExecuteLoopClosedMarker(conversationContext)
|
||||
}
|
||||
flowState.ResetForNextRun()
|
||||
log.Printf(
|
||||
"[DEBUG] chat reset runtime for next run chat=%s round_before=%d terminal_before=%s",
|
||||
flowState.ConversationID,
|
||||
roundBefore,
|
||||
terminalBefore,
|
||||
)
|
||||
}
|
||||
nonce := uuid.NewString()
|
||||
messages := agentprompt.BuildChatRoutingMessages(conversationContext, input.UserInput, flowState, nonce)
|
||||
messages = compactUnifiedMessagesIfNeeded(ctx, messages, UnifiedCompactInput{
|
||||
Client: input.Client,
|
||||
CompactionStore: input.CompactionStore,
|
||||
FlowState: flowState,
|
||||
Emitter: emitter,
|
||||
StageName: chatStageName,
|
||||
StatusBlockID: chatStatusBlockID,
|
||||
})
|
||||
logNodeLLMContext(chatStageName, "routing", flowState, messages)
|
||||
|
||||
reader, err := input.Client.Stream(ctx, messages, llmservice.GenerateOptions{
|
||||
Temperature: 0.7,
|
||||
Thinking: llmservice.ThinkingModeDisabled,
|
||||
Metadata: map[string]any{
|
||||
"stage": chatStageName,
|
||||
"phase": "routing",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[WARN] chat routing stream failed chat=%s err=%v", flowState.ConversationID, err)
|
||||
flowState.Phase = agentmodel.PhasePlanning
|
||||
return nil
|
||||
}
|
||||
|
||||
parser := agentrouter.NewStreamRouteParser(nonce)
|
||||
return streamAndDispatch(ctx, reader, parser, input, emitter, flowState, conversationContext)
|
||||
}
|
||||
|
||||
// appendExecuteLoopClosedMarker 在 history 中写入"execute loop 已正常收口"标记。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责写一个轻量 marker,供 prompt 分层;
|
||||
// 2. 不负责历史裁剪,不负责消息摘要;
|
||||
// 3. 若末尾已经是同类 marker,则幂等跳过,避免重复写入。
|
||||
func appendExecuteLoopClosedMarker(conversationContext *agentmodel.ConversationContext) {
|
||||
if conversationContext == nil {
|
||||
return
|
||||
}
|
||||
|
||||
history := conversationContext.HistorySnapshot()
|
||||
if len(history) > 0 {
|
||||
last := history[len(history)-1]
|
||||
if isExecuteLoopClosedMarker(last) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: "",
|
||||
Extra: map[string]any{
|
||||
chatHistoryKindKey: chatHistoryKindExecuteLoopClosed,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func isExecuteLoopClosedMarker(msg *schema.Message) bool {
|
||||
if msg == nil || msg.Extra == nil {
|
||||
return false
|
||||
}
|
||||
kind, ok := msg.Extra[chatHistoryKindKey].(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(kind) == chatHistoryKindExecuteLoopClosed
|
||||
}
|
||||
|
||||
// streamAndDispatch 是流式路由分发的核心循环。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 从 StreamReader 逐 chunk 读取,喂给 StreamRouteParser 增量解析控制码;
|
||||
// 2. 控制码解析完成后,根据 route 进入对应的流式处理分支;
|
||||
// 3. 控制码解析超时或流异常结束 → fallback 到 plan。
|
||||
func streamAndDispatch(
|
||||
ctx context.Context,
|
||||
reader llmservice.StreamReader,
|
||||
parser *agentrouter.StreamRouteParser,
|
||||
input ChatNodeInput,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
flowState *agentmodel.CommonState,
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
) error {
|
||||
for {
|
||||
chunk, err := reader.Recv()
|
||||
if err == io.EOF {
|
||||
if !parser.RouteReady() {
|
||||
log.Printf("[WARN] chat stream ended before route resolved chat=%s", flowState.ConversationID)
|
||||
flowState.Phase = agentmodel.PhasePlanning
|
||||
return nil
|
||||
}
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("[WARN] chat stream recv error chat=%s err=%v", flowState.ConversationID, err)
|
||||
flowState.Phase = agentmodel.PhasePlanning
|
||||
return nil
|
||||
}
|
||||
|
||||
content := ""
|
||||
if chunk != nil {
|
||||
content = chunk.Content
|
||||
}
|
||||
|
||||
visible, routeReady, _ := parser.Feed(content)
|
||||
if !routeReady {
|
||||
continue
|
||||
}
|
||||
|
||||
// 控制码解析完成,进入路由分发。
|
||||
decision := parser.Decision()
|
||||
|
||||
// 二次粗排硬闸门:若上下文已存在 rough_build_done 且用户未明确要求"重新粗排",
|
||||
// 则强制关闭 needs_rough_build,避免"微调请求被误判成再次粗排"。
|
||||
if shouldDisableRoughBuildForRefine(conversationContext, input.UserInput, decision) {
|
||||
decision.NeedsRoughBuild = false
|
||||
decision.NeedsRefineAfterRoughBuild = false
|
||||
}
|
||||
// 首次粗排兜底:若用户未明确要求"只要初稿不优化",则粗排后默认进入主动微调。
|
||||
if shouldForceRefineAfterFirstRoughBuild(conversationContext, input.UserInput, decision) {
|
||||
decision.NeedsRefineAfterRoughBuild = true
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"[DEBUG] chat routing chat=%s route=%s needs_rough_build=%v needs_refine_after_rough_build=%v allow_reorder=%v thinking=%v has_rough_build_done=%v task_class_count=%d raw=%s",
|
||||
flowState.ConversationID,
|
||||
decision.Route,
|
||||
decision.NeedsRoughBuild,
|
||||
decision.NeedsRefineAfterRoughBuild,
|
||||
decision.AllowReorder,
|
||||
decision.Thinking,
|
||||
hasRoughBuildDoneMarker(conversationContext),
|
||||
len(flowState.TaskClassIDs),
|
||||
decision.Raw,
|
||||
)
|
||||
|
||||
flowState.AllowReorder = resolveAllowReorder(input.UserInput, decision.AllowReorder)
|
||||
effectiveThinking := resolveEffectiveThinking(flowState.ThinkingMode, decision.Route, decision.Thinking)
|
||||
|
||||
switch decision.Route {
|
||||
case agentmodel.ChatRouteDirectReply:
|
||||
return handleDirectReplyStream(ctx, reader, input, emitter, conversationContext, flowState, effectiveThinking, visible)
|
||||
|
||||
case agentmodel.ChatRouteExecute:
|
||||
return handleRouteExecuteStream(reader, emitter, flowState, decision, input.UserInput, effectiveThinking, visible)
|
||||
|
||||
case agentmodel.ChatRouteDeepAnswer:
|
||||
return handleDeepAnswerStream(ctx, reader, input, emitter, conversationContext, flowState, effectiveThinking)
|
||||
|
||||
case agentmodel.ChatRoutePlan:
|
||||
return handleRoutePlanStream(reader, emitter, flowState, effectiveThinking, visible)
|
||||
|
||||
case agentmodel.ChatRouteQuickTask:
|
||||
// 关闭路由流,后续由 QuickTask 节点自行处理。
|
||||
_ = reader.Close()
|
||||
flowState.Phase = agentmodel.PhaseQuickTask
|
||||
return nil
|
||||
|
||||
default:
|
||||
flowState.Phase = agentmodel.PhasePlanning
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveEffectiveThinking 根据前端 ThinkingMode 和路由决策合并出最终 thinking 状态。
|
||||
//
|
||||
// 规则:
|
||||
// 1. "true":前端强制开启,所有路由统一开;
|
||||
// 2. "false":前端强制关闭,所有路由统一关;
|
||||
// 3. "auto"/"":按路由语义兜底;
|
||||
// 3.1 deep_answer 的语义本身就是"复杂问答 + 原地深度思考",因此默认开启;
|
||||
// 3.2 execute 继续沿用路由模型给出的 decisionThinking;
|
||||
// 3.3 其余路由默认关闭,避免把轻量闲聊误升成高成本推理。
|
||||
func resolveEffectiveThinking(mode string, route agentmodel.ChatRoute, decisionThinking bool) bool {
|
||||
switch strings.TrimSpace(strings.ToLower(mode)) {
|
||||
case "true":
|
||||
return true
|
||||
case "false":
|
||||
return false
|
||||
default:
|
||||
if route == agentmodel.ChatRouteDeepAnswer {
|
||||
return true
|
||||
}
|
||||
return decisionThinking
|
||||
}
|
||||
}
|
||||
|
||||
// handleDirectReplyStream 处理闲聊回复。
|
||||
//
|
||||
// 两种模式:
|
||||
// 1. thinking=false:同一流续传,逐 chunk 推送;
|
||||
// 2. thinking=true:关闭路由流,发起第二次 thinking 流式调用。
|
||||
func handleDirectReplyStream(
|
||||
ctx context.Context,
|
||||
reader llmservice.StreamReader,
|
||||
input ChatNodeInput,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
flowState *agentmodel.CommonState,
|
||||
effectiveThinking bool,
|
||||
firstVisible string,
|
||||
) error {
|
||||
if effectiveThinking {
|
||||
return handleThinkingReplyStream(ctx, reader, input, emitter, conversationContext, flowState)
|
||||
}
|
||||
return handleDirectReplyContinueStream(ctx, reader, input, emitter, conversationContext, flowState, firstVisible)
|
||||
}
|
||||
|
||||
// handleThinkingReplyStream 处理需要思考的回复:关闭路由流 → 第二次 thinking 流式调用。
|
||||
func handleThinkingReplyStream(
|
||||
ctx context.Context,
|
||||
reader llmservice.StreamReader,
|
||||
input ChatNodeInput,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
flowState *agentmodel.CommonState,
|
||||
) error {
|
||||
_ = reader.Close()
|
||||
|
||||
deepMessages := agentprompt.BuildDeepAnswerMessages(flowState, conversationContext, input.UserInput)
|
||||
deepMessages = compactUnifiedMessagesIfNeeded(ctx, deepMessages, UnifiedCompactInput{
|
||||
Client: input.Client,
|
||||
CompactionStore: input.CompactionStore,
|
||||
FlowState: flowState,
|
||||
Emitter: emitter,
|
||||
StageName: chatStageName,
|
||||
StatusBlockID: chatStatusBlockID,
|
||||
})
|
||||
logNodeLLMContext(chatStageName, "direct_reply_thinking", flowState, deepMessages)
|
||||
deepReader, err := input.Client.Stream(ctx, deepMessages, llmservice.GenerateOptions{
|
||||
Temperature: 0.5,
|
||||
MaxTokens: 2000,
|
||||
Thinking: llmservice.ThinkingModeEnabled,
|
||||
Metadata: map[string]any{
|
||||
"stage": chatStageName,
|
||||
"phase": "direct_reply_thinking",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[WARN] thinking reply stream failed chat=%s err=%v", flowState.ConversationID, err)
|
||||
flowState.Phase = agentmodel.PhaseChatting
|
||||
return nil
|
||||
}
|
||||
|
||||
deepText, err := emitter.EmitStreamAssistantText(ctx, deepReader, chatSpeakBlockID, chatStageName)
|
||||
_ = deepReader.Close()
|
||||
if err != nil {
|
||||
log.Printf("[WARN] thinking reply emit error chat=%s err=%v", flowState.ConversationID, err)
|
||||
flowState.Phase = agentmodel.PhaseChatting
|
||||
return nil
|
||||
}
|
||||
|
||||
deepText = strings.TrimSpace(deepText)
|
||||
if deepText != "" {
|
||||
conversationContext.AppendHistory(schema.AssistantMessage(deepText, nil))
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, schema.AssistantMessage(deepText, nil))
|
||||
}
|
||||
|
||||
flowState.Phase = agentmodel.PhaseChatting
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleDirectReplyContinueStream 处理无思考的闲聊:同一流续传。
|
||||
func handleDirectReplyContinueStream(
|
||||
ctx context.Context,
|
||||
reader llmservice.StreamReader,
|
||||
input ChatNodeInput,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
flowState *agentmodel.CommonState,
|
||||
firstVisible string,
|
||||
) error {
|
||||
var fullText strings.Builder
|
||||
fullText.WriteString(firstVisible)
|
||||
|
||||
// 推送控制码之后的第一段内容。
|
||||
if strings.TrimSpace(firstVisible) != "" {
|
||||
if err := emitter.EmitAssistantText(chatSpeakBlockID, chatStageName, firstVisible, true); err != nil {
|
||||
return fmt.Errorf("闲聊回复推送失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
firstChunk := firstVisible == ""
|
||||
// 继续读同一个流,逐 chunk 推送。
|
||||
for {
|
||||
chunk, err := reader.Recv()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("[WARN] direct_reply stream error chat=%s err=%v", flowState.ConversationID, err)
|
||||
break
|
||||
}
|
||||
if chunk == nil || chunk.Content == "" {
|
||||
continue
|
||||
}
|
||||
if err := emitter.EmitAssistantText(chatSpeakBlockID, chatStageName, chunk.Content, firstChunk); err != nil {
|
||||
return fmt.Errorf("闲聊回复推送失败: %w", err)
|
||||
}
|
||||
fullText.WriteString(chunk.Content)
|
||||
firstChunk = false
|
||||
}
|
||||
|
||||
text := fullText.String()
|
||||
if strings.TrimSpace(text) != "" {
|
||||
msg := schema.AssistantMessage(text, nil)
|
||||
conversationContext.AppendHistory(msg)
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
}
|
||||
|
||||
flowState.Phase = agentmodel.PhaseChatting
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleRouteExecuteStream 处理工具调用路由:推送状态确认 → 设 PhaseExecuting。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 关闭路由流(后续内容不需要);
|
||||
// 2. 推送轻量状态通知;
|
||||
// 3. 设置流程状态,进入 Execute 或 RoughBuild。
|
||||
func handleRouteExecuteStream(
|
||||
reader llmservice.StreamReader,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
flowState *agentmodel.CommonState,
|
||||
decision *agentmodel.ChatRoutingDecision,
|
||||
userInput string,
|
||||
effectiveThinking bool,
|
||||
speak string,
|
||||
) error {
|
||||
// 关闭路由流。
|
||||
_ = reader.Close()
|
||||
|
||||
if strings.TrimSpace(speak) == "" {
|
||||
speak = "好的,我来处理。"
|
||||
}
|
||||
|
||||
// 推送轻量状态通知。
|
||||
_ = emitter.EmitStatus(chatStatusBlockID, chatStageName, "accepted", speak, false)
|
||||
|
||||
// 清空旧 PlanSteps 并设 PhaseExecuting。
|
||||
flowState.StartDirectExecute()
|
||||
|
||||
// 粗排开关逻辑。
|
||||
flowState.NeedsRoughBuild = false
|
||||
flowState.NeedsRefineAfterRoughBuild = false
|
||||
if decision.NeedsRoughBuild && len(flowState.TaskClassIDs) > 0 {
|
||||
flowState.NeedsRoughBuild = true
|
||||
flowState.NeedsRefineAfterRoughBuild = decision.NeedsRefineAfterRoughBuild
|
||||
}
|
||||
|
||||
flowState.ExecuteThinking = effectiveThinking
|
||||
flowState.OptimizationMode = resolveOptimizationMode(userInput, decision, flowState)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveAllowReorder 统一计算"本轮是否允许打乱顺序"。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 后端先做显式语义判定:用户明确允许/明确禁止时,直接以后端判定为准;
|
||||
// 2. 若后端未识别到显式语义,再回退到路由模型的 allow_reorder 字段;
|
||||
// 3. 默认返回 false,确保"保持顺序"是系统默认行为。
|
||||
func resolveAllowReorder(userInput string, modelAllowReorder bool) bool {
|
||||
switch detectReorderPreference(userInput) {
|
||||
case reorderAllow:
|
||||
return true
|
||||
case reorderDisallow:
|
||||
return false
|
||||
default:
|
||||
return modelAllowReorder
|
||||
}
|
||||
}
|
||||
|
||||
// detectReorderPreference 识别用户是否"明确授权打乱顺序"。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责关键词级别的显式意图识别,不做复杂语义推理;
|
||||
// 2. 若同时命中"允许"与"禁止",优先按"禁止"处理,避免误放开顺序约束;
|
||||
// 3. 未命中显式表达时返回 unknown,交给上层兜底策略。
|
||||
func detectReorderPreference(userInput string) reorderPreference {
|
||||
text := strings.ToLower(strings.TrimSpace(userInput))
|
||||
if text == "" {
|
||||
return reorderUnknown
|
||||
}
|
||||
|
||||
disallowPhrases := []string{
|
||||
"不要打乱顺序",
|
||||
"不允许打乱顺序",
|
||||
"保持顺序",
|
||||
"顺序不变",
|
||||
"按原顺序",
|
||||
"不要乱序",
|
||||
"别打乱",
|
||||
}
|
||||
if containsAnyPhrase(text, disallowPhrases) {
|
||||
return reorderDisallow
|
||||
}
|
||||
|
||||
allowPhrases := []string{
|
||||
"可以打乱顺序",
|
||||
"允许打乱顺序",
|
||||
"顺序不重要",
|
||||
"顺序无所谓",
|
||||
"顺序不限",
|
||||
"允许乱序",
|
||||
"可以乱序",
|
||||
"允许重排顺序",
|
||||
"reorder is fine",
|
||||
"any order",
|
||||
}
|
||||
if containsAnyPhrase(text, allowPhrases) {
|
||||
return reorderAllow
|
||||
}
|
||||
|
||||
return reorderUnknown
|
||||
}
|
||||
|
||||
// resolveOptimizationMode 统一确定当前 execute 的优化模式。
|
||||
func resolveOptimizationMode(
|
||||
userInput string,
|
||||
decision *agentmodel.ChatRoutingDecision,
|
||||
flowState *agentmodel.CommonState,
|
||||
) string {
|
||||
if decision != nil && decision.NeedsRoughBuild && flowState != nil && len(flowState.TaskClassIDs) > 0 {
|
||||
return "first_full"
|
||||
}
|
||||
if isExplicitGlobalReoptRequest(userInput) {
|
||||
return "global_reopt"
|
||||
}
|
||||
return "local_adjust"
|
||||
}
|
||||
|
||||
// isExplicitGlobalReoptRequest 识别用户是否明确要求全局重优化。
|
||||
func isExplicitGlobalReoptRequest(userInput string) bool {
|
||||
text := strings.ToLower(strings.TrimSpace(userInput))
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
keywords := []string{
|
||||
"全局优化",
|
||||
"整体优化",
|
||||
"全局重排",
|
||||
"整体重排",
|
||||
"重新优化全部",
|
||||
"重新优化整体",
|
||||
"全面优化",
|
||||
"整体体检",
|
||||
"全局体检",
|
||||
"重新体检",
|
||||
"global optimize",
|
||||
"global reopt",
|
||||
"overall optimize",
|
||||
}
|
||||
return containsAnyPhrase(text, keywords)
|
||||
}
|
||||
|
||||
func containsAnyPhrase(text string, phrases []string) bool {
|
||||
for _, phrase := range phrases {
|
||||
if strings.Contains(text, phrase) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// shouldDisableRoughBuildForRefine 判断是否应在 chat 路由阶段关闭"再次粗排"。
|
||||
//
|
||||
// 判定规则:
|
||||
// 1. 当前决策未请求粗排时,直接不干预;
|
||||
// 2. 上下文不存在 rough_build_done 时,不干预(首次粗排仍可走);
|
||||
// 3. 若用户未明确要求"重新粗排/从头重排",则关闭粗排开关,避免误触发。
|
||||
func shouldDisableRoughBuildForRefine(
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
userInput string,
|
||||
decision *agentmodel.ChatRoutingDecision,
|
||||
) bool {
|
||||
if decision == nil || !decision.NeedsRoughBuild {
|
||||
return false
|
||||
}
|
||||
if !hasRoughBuildDoneMarker(conversationContext) {
|
||||
return false
|
||||
}
|
||||
return !isExplicitRoughBuildRequest(userInput)
|
||||
}
|
||||
|
||||
// shouldForceRefineAfterFirstRoughBuild 判断是否应在"首次粗排"场景下强制开启 refine。
|
||||
//
|
||||
// 判定规则:
|
||||
// 1. 仅在当前决策仍然请求粗排时生效;
|
||||
// 2. 仅在首次粗排(上下文不存在 rough_build_done)时生效;
|
||||
// 3. 若用户明确表达"只要初稿/先不优化",则不强制开启;
|
||||
// 4. 其余首次粗排场景一律开启,确保符合 PRD 的默认主动优化策略。
|
||||
func shouldForceRefineAfterFirstRoughBuild(
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
userInput string,
|
||||
decision *agentmodel.ChatRoutingDecision,
|
||||
) bool {
|
||||
if decision == nil || !decision.NeedsRoughBuild {
|
||||
return false
|
||||
}
|
||||
if hasRoughBuildDoneMarker(conversationContext) {
|
||||
return false
|
||||
}
|
||||
return !isExplicitNoRefineAfterRoughBuildRequest(userInput)
|
||||
}
|
||||
|
||||
func hasRoughBuildDoneMarker(conversationContext *agentmodel.ConversationContext) bool {
|
||||
if conversationContext == nil {
|
||||
return false
|
||||
}
|
||||
for _, block := range conversationContext.PinnedBlocksSnapshot() {
|
||||
if strings.TrimSpace(block.Key) == "rough_build_done" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isExplicitRoughBuildRequest 识别用户是否明确要求"重新粗排/从头重排"。
|
||||
func isExplicitRoughBuildRequest(userInput string) bool {
|
||||
text := strings.ToLower(strings.TrimSpace(userInput))
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
keywords := []string{
|
||||
"重新粗排",
|
||||
"重做粗排",
|
||||
"从头排",
|
||||
"从头重排",
|
||||
"重新排一遍",
|
||||
"重新排课",
|
||||
"重排全部",
|
||||
"全部重排",
|
||||
"重置排程",
|
||||
"重置后重排",
|
||||
"重新生成初稿",
|
||||
"rebuild",
|
||||
"from scratch",
|
||||
}
|
||||
return containsAnyPhrase(text, keywords)
|
||||
}
|
||||
|
||||
// isExplicitNoRefineAfterRoughBuildRequest 识别用户是否明确要求"粗排后先不要自动微调"。
|
||||
func isExplicitNoRefineAfterRoughBuildRequest(userInput string) bool {
|
||||
text := strings.ToLower(strings.TrimSpace(userInput))
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
keywords := []string{
|
||||
"只要初稿",
|
||||
"先给初稿",
|
||||
"先排进去就行",
|
||||
"先排进去",
|
||||
"先不优化",
|
||||
"先别优化",
|
||||
"先不微调",
|
||||
"先别微调",
|
||||
"排完就收口",
|
||||
"粗排就行",
|
||||
"草稿就行",
|
||||
"draft only",
|
||||
"no refine",
|
||||
"no optimization",
|
||||
}
|
||||
return containsAnyPhrase(text, keywords)
|
||||
}
|
||||
|
||||
// handleDeepAnswerStream 处理复杂问答:关闭路由流 → 第二次流式调用。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 关闭第一个路由流;
|
||||
// 2. 发起第二次流式 LLM 调用(thinking 由 effectiveThinking 控制);
|
||||
// 3. 真流式推送 reasoning + 正文;
|
||||
// 4. 完整回复写入 history。
|
||||
func handleDeepAnswerStream(
|
||||
ctx context.Context,
|
||||
reader llmservice.StreamReader,
|
||||
input ChatNodeInput,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
flowState *agentmodel.CommonState,
|
||||
effectiveThinking bool,
|
||||
) error {
|
||||
// 1. 关闭第一个路由流。
|
||||
_ = reader.Close()
|
||||
|
||||
// 2. 第二次流式调用。
|
||||
thinkingOpt := llmservice.ThinkingModeDisabled
|
||||
if effectiveThinking {
|
||||
thinkingOpt = llmservice.ThinkingModeEnabled
|
||||
}
|
||||
deepMessages := agentprompt.BuildDeepAnswerMessages(flowState, conversationContext, input.UserInput)
|
||||
deepMessages = compactUnifiedMessagesIfNeeded(ctx, deepMessages, UnifiedCompactInput{
|
||||
Client: input.Client,
|
||||
CompactionStore: input.CompactionStore,
|
||||
FlowState: flowState,
|
||||
Emitter: emitter,
|
||||
StageName: chatStageName,
|
||||
StatusBlockID: chatStatusBlockID,
|
||||
})
|
||||
logNodeLLMContext(chatStageName, "deep_answer", flowState, deepMessages)
|
||||
deepReader, err := input.Client.Stream(ctx, deepMessages, llmservice.GenerateOptions{
|
||||
Temperature: 0.5,
|
||||
MaxTokens: 2000,
|
||||
Thinking: thinkingOpt,
|
||||
Metadata: map[string]any{
|
||||
"stage": chatStageName,
|
||||
"phase": "deep_answer",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
// 深度回答失败 → 降级返回。
|
||||
log.Printf("[WARN] deep answer stream failed chat=%s err=%v", flowState.ConversationID, err)
|
||||
flowState.Phase = agentmodel.PhaseChatting
|
||||
return nil
|
||||
}
|
||||
|
||||
// 3. 真流式推送 reasoning + 正文。
|
||||
deepText, err := emitter.EmitStreamAssistantText(ctx, deepReader, chatSpeakBlockID, chatStageName)
|
||||
_ = deepReader.Close()
|
||||
if err != nil {
|
||||
log.Printf("[WARN] deep answer stream emit error chat=%s err=%v", flowState.ConversationID, err)
|
||||
flowState.Phase = agentmodel.PhaseChatting
|
||||
return nil
|
||||
}
|
||||
|
||||
deepText = strings.TrimSpace(deepText)
|
||||
if deepText == "" {
|
||||
flowState.Phase = agentmodel.PhaseChatting
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. 完整回复写入 history。
|
||||
msg := schema.AssistantMessage(deepText, nil)
|
||||
conversationContext.AppendHistory(msg)
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
|
||||
flowState.Phase = agentmodel.PhaseChatting
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleRoutePlanStream 处理规划路由:推送状态确认 → 设 PhasePlanning。
|
||||
func handleRoutePlanStream(
|
||||
reader llmservice.StreamReader,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
flowState *agentmodel.CommonState,
|
||||
effectiveThinking bool,
|
||||
speak string,
|
||||
) error {
|
||||
// 关闭路由流。
|
||||
_ = reader.Close()
|
||||
|
||||
if strings.TrimSpace(speak) == "" {
|
||||
speak = "好的,让我来规划一下。"
|
||||
}
|
||||
|
||||
_ = emitter.EmitStatus(chatStatusBlockID, chatStageName, "planning", speak, false)
|
||||
|
||||
flowState.Phase = agentmodel.PhasePlanning
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── 恢复处理(保持原有逻辑不变)───
|
||||
|
||||
// handleChatResume 处理 pending interaction 恢复。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做状态传递:吞掉用户输入、写回历史、恢复 phase;
|
||||
// 2. 不生成 speak,真正的回复由下游 Plan / Execute 节点产出;
|
||||
// 3. 只推送轻量 status 通知前端"已收到回复,正在继续"。
|
||||
func handleChatResume(
|
||||
input ChatNodeInput,
|
||||
runtimeState *agentmodel.AgentRuntimeState,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
) error {
|
||||
pending := runtimeState.PendingInteraction
|
||||
flowState := runtimeState.EnsureCommonState()
|
||||
|
||||
if isMismatchedResumeInteraction(input.ResumeInteractionID, pending) {
|
||||
_ = emitter.EmitStatus(
|
||||
chatStatusBlockID, chatStageName,
|
||||
"stale_resume", "当前确认已过期,请刷新后重试。", false,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 用户输入在 service 层进入 graph 前已经统一追加到 ConversationContext。
|
||||
// 这里不再二次写入,避免 pending 恢复路径把同一轮 user message 追加两次。
|
||||
|
||||
switch pending.Type {
|
||||
case agentmodel.PendingInteractionTypeAskUser:
|
||||
// 用户回答了问题 → 恢复 phase,交给下游节点继续。
|
||||
runtimeState.ResumeFromPending()
|
||||
_ = emitter.EmitStatus(
|
||||
chatStatusBlockID, chatStageName,
|
||||
"resumed", "收到回复,继续处理。", false,
|
||||
)
|
||||
return nil
|
||||
|
||||
case agentmodel.PendingInteractionTypeConfirm:
|
||||
return handleConfirmResume(input, runtimeState, flowState, pending, emitter)
|
||||
|
||||
default:
|
||||
// connection_lost 等其他类型 → 直接恢复。
|
||||
runtimeState.ResumeFromPending()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// handleConfirmResume 处理 confirm 类型恢复。
|
||||
//
|
||||
// 分支逻辑:
|
||||
// 1. accept → 恢复后 phase 设为 executing,下游 Execute 节点接管;
|
||||
// 2. reject + 有 PendingTool(工具确认)→ 回到 executing 让 Execute 节点换策略;
|
||||
// 3. reject + 无 PendingTool(计划确认)→ 清空计划,回到 planning 重新规划。
|
||||
func handleConfirmResume(
|
||||
input ChatNodeInput,
|
||||
runtimeState *agentmodel.AgentRuntimeState,
|
||||
flowState *agentmodel.CommonState,
|
||||
pending *agentmodel.PendingInteraction,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
) error {
|
||||
if isMismatchedResumeInteraction(input.ResumeInteractionID, pending) {
|
||||
_ = emitter.EmitStatus(
|
||||
chatStatusBlockID, chatStageName,
|
||||
"stale_resume", "当前确认已过期,请刷新后重试。", false,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
action := strings.ToLower(strings.TrimSpace(input.ConfirmAction))
|
||||
|
||||
switch action {
|
||||
case "accept", "approve":
|
||||
// 恢复前保存待执行工具,Execute 节点需要它。
|
||||
pendingTool := pending.PendingTool
|
||||
runtimeState.ResumeFromPending()
|
||||
// 将待执行工具放回临时邮箱,供 Execute 节点执行。
|
||||
if pendingTool != nil {
|
||||
copied := *pendingTool
|
||||
runtimeState.PendingConfirmTool = &copied
|
||||
}
|
||||
flowState.Phase = agentmodel.PhaseExecuting
|
||||
_ = emitter.EmitStatus(
|
||||
chatStatusBlockID, chatStageName,
|
||||
"confirmed", "已确认,开始执行。", false,
|
||||
)
|
||||
|
||||
case "reject", "cancel":
|
||||
runtimeState.ResumeFromPending()
|
||||
if pending.PendingTool != nil {
|
||||
// 工具确认被拒 → 回到 executing 换策略。
|
||||
flowState.Phase = agentmodel.PhaseExecuting
|
||||
} else {
|
||||
// 计划确认被拒 → 清空计划,回到 planning。
|
||||
flowState.RejectPlan()
|
||||
}
|
||||
_ = emitter.EmitStatus(
|
||||
chatStatusBlockID, chatStageName,
|
||||
"rejected", "已取消,准备重新规划。", false,
|
||||
)
|
||||
|
||||
default:
|
||||
_ = emitter.EmitStatus(
|
||||
chatStatusBlockID, chatStageName,
|
||||
"invalid_confirm_action", "未识别确认动作,请重试。", false,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isMismatchedResumeInteraction(resumeInteractionID string, pending *agentmodel.PendingInteraction) bool {
|
||||
if pending == nil {
|
||||
return false
|
||||
}
|
||||
resumeID := strings.TrimSpace(resumeInteractionID)
|
||||
pendingID := strings.TrimSpace(pending.InteractionID)
|
||||
if resumeID == "" || pendingID == "" {
|
||||
return false
|
||||
}
|
||||
return resumeID != pendingID
|
||||
}
|
||||
|
||||
// prepareChatNodeInput 校验并准备聊天节点的运行态依赖。
|
||||
func prepareChatNodeInput(input ChatNodeInput) (
|
||||
*agentmodel.AgentRuntimeState,
|
||||
*agentmodel.ConversationContext,
|
||||
*agentstream.ChunkEmitter,
|
||||
error,
|
||||
) {
|
||||
if input.RuntimeState == nil {
|
||||
return nil, nil, nil, fmt.Errorf("chat node: runtime state 不能为空")
|
||||
}
|
||||
if input.Client == nil {
|
||||
return nil, nil, nil, fmt.Errorf("chat node: chat client 未注入")
|
||||
}
|
||||
|
||||
input.RuntimeState.EnsureCommonState()
|
||||
if input.ConversationContext == nil {
|
||||
input.ConversationContext = agentmodel.NewConversationContext("")
|
||||
}
|
||||
if input.ChunkEmitter == nil {
|
||||
input.ChunkEmitter = agentstream.NewChunkEmitter(
|
||||
agentstream.NoopPayloadEmitter(), "", "", time.Now().Unix(),
|
||||
)
|
||||
}
|
||||
return input.RuntimeState, input.ConversationContext, input.ChunkEmitter, nil
|
||||
}
|
||||
208
backend/services/agent/node/confirm.go
Normal file
208
backend/services/agent/node/confirm.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
)
|
||||
|
||||
const (
|
||||
confirmStageName = "confirm"
|
||||
confirmStatusBlockID = "confirm.status"
|
||||
)
|
||||
|
||||
// ConfirmNodeInput 描述确认节点单轮运行所需的最小依赖。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 不需要 LLM Client — 确认内容由已有状态机械格式化,不调模型;
|
||||
// 2. RuntimeState 提供计划步骤和待确认工具快照;
|
||||
// 3. ChunkEmitter 负责推送确认事件到前端。
|
||||
type ConfirmNodeInput struct {
|
||||
RuntimeState *agentmodel.AgentRuntimeState
|
||||
ConversationContext *agentmodel.ConversationContext
|
||||
ChunkEmitter *agentstream.ChunkEmitter
|
||||
}
|
||||
|
||||
// RunConfirmNode 执行一轮确认节点逻辑。
|
||||
//
|
||||
// 核心职责:
|
||||
// 1. 判断确认来源:有 PendingConfirmTool → 工具确认;有 PlanSteps → 计划确认;
|
||||
// 2. 机械格式化确认内容(不需要 LLM 调用);
|
||||
// 3. 推送确认事件 EmitConfirmRequest → 前端渲染确认卡片;
|
||||
// 4. 调用 OpenConfirmInteraction 固化中断快照,Phase 自动变为 interrupted。
|
||||
//
|
||||
// 设计原则:
|
||||
// 1. 不等待用户响应 — 等待是 interruptNode 的职责;
|
||||
// 2. 不执行任何工具 — 只固化"意图",执行留给恢复后的 Execute;
|
||||
// 3. Confirm 是图里唯一负责"生成确认事件 + 固化快照"的地方,上游节点只设 Phase。
|
||||
func RunConfirmNode(ctx context.Context, input ConfirmNodeInput) error {
|
||||
runtimeState, _, emitter, err := prepareConfirmNodeInput(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
flowState := runtimeState.EnsureCommonState()
|
||||
|
||||
// 优先处理工具确认(Execute 发起的写操作确认)。
|
||||
if runtimeState.PendingConfirmTool != nil {
|
||||
return handleToolConfirm(ctx, runtimeState, flowState, emitter)
|
||||
}
|
||||
|
||||
// 其次处理计划确认(Plan 完成后的整体验收)。
|
||||
if flowState.HasPlan() {
|
||||
return handlePlanConfirm(ctx, runtimeState, flowState, emitter)
|
||||
}
|
||||
|
||||
// 既没有工具也没有计划 → 异常状态,不应到达此处。
|
||||
return fmt.Errorf("confirm node: 没有可确认的内容(无计划、无待确认工具)")
|
||||
}
|
||||
|
||||
// handlePlanConfirm 处理计划确认。
|
||||
//
|
||||
// 流程:
|
||||
// 1. 从 flowState.PlanSteps 格式化可读摘要;
|
||||
// 2. 推送确认事件到前端;
|
||||
// 3. 调用 OpenConfirmInteraction 固化快照(无 PendingTool)。
|
||||
func handlePlanConfirm(
|
||||
ctx context.Context,
|
||||
runtimeState *agentmodel.AgentRuntimeState,
|
||||
flowState *agentmodel.CommonState,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
) error {
|
||||
summary := buildPlanSummary(flowState.PlanSteps)
|
||||
interactionID := generateConfirmInteractionID(flowState)
|
||||
|
||||
if err := emitter.EmitConfirmRequest(
|
||||
ctx, confirmStatusBlockID, confirmStageName,
|
||||
interactionID,
|
||||
"计划确认",
|
||||
summary,
|
||||
agentstream.DefaultPseudoStreamOptions(),
|
||||
); err != nil {
|
||||
return fmt.Errorf("计划确认事件推送失败: %w", err)
|
||||
}
|
||||
|
||||
runtimeState.OpenConfirmInteraction(
|
||||
interactionID,
|
||||
summary,
|
||||
"plan",
|
||||
nil,
|
||||
)
|
||||
|
||||
_ = emitter.EmitStatus(
|
||||
confirmStatusBlockID, confirmStageName,
|
||||
"plan_confirm", "计划已生成,等待用户确认。", false,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleToolConfirm 处理工具确认。
|
||||
//
|
||||
// 流程:
|
||||
// 1. 从 PendingConfirmTool 构建确认摘要;
|
||||
// 2. 推送确认事件到前端;
|
||||
// 3. 调用 OpenConfirmInteraction 固化快照(含 PendingTool);
|
||||
// 4. 清空 PendingConfirmTool 临时邮箱。
|
||||
func handleToolConfirm(
|
||||
ctx context.Context,
|
||||
runtimeState *agentmodel.AgentRuntimeState,
|
||||
flowState *agentmodel.CommonState,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
) error {
|
||||
pendingTool := runtimeState.PendingConfirmTool
|
||||
summary := buildToolConfirmSummary(pendingTool)
|
||||
interactionID := generateConfirmInteractionID(flowState)
|
||||
|
||||
if err := emitter.EmitConfirmRequest(
|
||||
ctx, confirmStatusBlockID, confirmStageName,
|
||||
interactionID,
|
||||
"操作确认",
|
||||
summary,
|
||||
agentstream.DefaultPseudoStreamOptions(),
|
||||
); err != nil {
|
||||
return fmt.Errorf("工具确认事件推送失败: %w", err)
|
||||
}
|
||||
|
||||
runtimeState.OpenConfirmInteraction(
|
||||
interactionID,
|
||||
summary,
|
||||
"execute",
|
||||
pendingTool,
|
||||
)
|
||||
|
||||
// 确认快照已固化到 PendingInteraction,清空临时邮箱。
|
||||
runtimeState.PendingConfirmTool = nil
|
||||
|
||||
_ = emitter.EmitStatus(
|
||||
confirmStatusBlockID, confirmStageName,
|
||||
"tool_confirm", "操作等待确认。", false,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildPlanSummary 把 PlanSteps 格式化成人类可读的确认摘要。
|
||||
func buildPlanSummary(steps []agentmodel.PlanStep) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("共 %d 步:\n", len(steps)))
|
||||
for i, step := range steps {
|
||||
sb.WriteString(fmt.Sprintf("%d. %s", i+1, step.Content))
|
||||
if step.DoneWhen != "" {
|
||||
sb.WriteString(fmt.Sprintf("(完成条件:%s)", step.DoneWhen))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// buildToolConfirmSummary 从工具快照构建确认摘要。
|
||||
func buildToolConfirmSummary(tool *agentmodel.PendingToolCallSnapshot) string {
|
||||
if tool == nil {
|
||||
return "待确认操作"
|
||||
}
|
||||
if tool.Summary != "" {
|
||||
return tool.Summary
|
||||
}
|
||||
detail := fmt.Sprintf("即将执行工具:%s", tool.ToolName)
|
||||
if tool.ArgsJSON != "" {
|
||||
var args map[string]any
|
||||
if json.Unmarshal([]byte(tool.ArgsJSON), &args) == nil && len(args) > 0 {
|
||||
detail += fmt.Sprintf(",参数:%s", tool.ArgsJSON)
|
||||
}
|
||||
}
|
||||
return detail
|
||||
}
|
||||
|
||||
// generateConfirmInteractionID 生成确认交互的唯一标识。
|
||||
func generateConfirmInteractionID(flowState *agentmodel.CommonState) string {
|
||||
prefix := flowState.TraceID
|
||||
if prefix == "" {
|
||||
prefix = "confirm"
|
||||
}
|
||||
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixMilli())
|
||||
}
|
||||
|
||||
// prepareConfirmNodeInput 校验并准备确认节点的运行态依赖。
|
||||
func prepareConfirmNodeInput(input ConfirmNodeInput) (
|
||||
*agentmodel.AgentRuntimeState,
|
||||
*agentmodel.ConversationContext,
|
||||
*agentstream.ChunkEmitter,
|
||||
error,
|
||||
) {
|
||||
if input.RuntimeState == nil {
|
||||
return nil, nil, nil, fmt.Errorf("confirm node: runtime state 不能为空")
|
||||
}
|
||||
input.RuntimeState.EnsureCommonState()
|
||||
if input.ConversationContext == nil {
|
||||
input.ConversationContext = agentmodel.NewConversationContext("")
|
||||
}
|
||||
if input.ChunkEmitter == nil {
|
||||
input.ChunkEmitter = agentstream.NewChunkEmitter(
|
||||
agentstream.NoopPayloadEmitter(), "", "", time.Now().Unix(),
|
||||
)
|
||||
}
|
||||
return input.RuntimeState, input.ConversationContext, input.ChunkEmitter, nil
|
||||
}
|
||||
136
backend/services/agent/node/correction.go
Normal file
136
backend/services/agent/node/correction.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
correctionHistoryKindKey = "newagent_history_kind"
|
||||
correctionHistoryKindCorrectionUser = "llm_correction_prompt"
|
||||
)
|
||||
|
||||
// AppendLLMCorrection 追加 LLM 修正提示到对话历史。
|
||||
//
|
||||
// 设计目的:
|
||||
// 1. 当 LLM 输出不符合预期(如不支持的 action、格式错误等),不应直接报错终止;
|
||||
// 2. 应该给 LLM 一个自我修正的机会,把错误反馈写回历史,让它重新生成;
|
||||
// 3. 该函数封装了"追加 assistant 消息 + 追加纠正提示"的通用流程。
|
||||
//
|
||||
// 参数说明:
|
||||
// - conversationContext: 对话上下文,用于追加历史消息;
|
||||
// - llmOutput: LLM 的原始输出内容,会作为 assistant 消息追加;
|
||||
// - validOptionsDesc: 合法选项的描述,用于构造纠正提示。
|
||||
//
|
||||
// 使用示例:
|
||||
//
|
||||
// AppendLLMCorrection(conversationContext, decision.Speak, "合法的 action 包括:continue、ask_user、next_plan、done")
|
||||
//
|
||||
// 返回值:
|
||||
// - 返回 nil 表示修正流程完成,调用方应继续 Graph 循环;
|
||||
// - 该函数不会返回 error,因为追加历史失败不影响主流程。
|
||||
func AppendLLMCorrection(
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
llmOutput string,
|
||||
validOptionsDesc string,
|
||||
) {
|
||||
if conversationContext == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 构造 assistant 消息,让 LLM 知道自己刚才输出了什么。
|
||||
// 2. 空输出不回灌,避免把占位文本写进历史造成噪音。
|
||||
// 3. 与最近一条 assistant 完全相同则跳过,避免重复回灌放大复读。
|
||||
assistantContent := strings.TrimSpace(llmOutput)
|
||||
appendCorrectionAssistantIfNeeded(conversationContext, assistantContent)
|
||||
|
||||
// 2. 构造纠正提示,明确告知 LLM 哪里错了、合法选项有哪些。
|
||||
// 不做硬编码的错误类型,由调用方通过 validOptionsDesc 传入。
|
||||
correctionContent := fmt.Sprintf(
|
||||
"你的输出不符合预期。%s 请重新分析当前状态,输出正确的内容。",
|
||||
validOptionsDesc,
|
||||
)
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.User,
|
||||
Content: correctionContent,
|
||||
Extra: map[string]any{
|
||||
correctionHistoryKindKey: correctionHistoryKindCorrectionUser,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// AppendLLMCorrectionWithHint 追加 LLM 修正提示(带自定义错误描述)。
|
||||
//
|
||||
// 相比 AppendLLMCorrection,该函数允许调用方提供更详细的错误描述,
|
||||
// 适用于需要明确告知 LLM 具体哪里出错的场景。
|
||||
//
|
||||
// 参数说明:
|
||||
// - conversationContext: 对话上下文;
|
||||
// - llmOutput: LLM 的原始输出内容;
|
||||
// - errorDesc: 具体的错误描述,如 "action \"invalid\" 不是合法的执行动作";
|
||||
// - validOptionsDesc: 合法选项的描述。
|
||||
func AppendLLMCorrectionWithHint(
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
llmOutput string,
|
||||
errorDesc string,
|
||||
validOptionsDesc string,
|
||||
) {
|
||||
if conversationContext == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assistantContent := strings.TrimSpace(llmOutput)
|
||||
appendCorrectionAssistantIfNeeded(conversationContext, assistantContent)
|
||||
|
||||
correctionContent := fmt.Sprintf(
|
||||
"%s %s 请重新分析当前状态,输出正确的内容。",
|
||||
errorDesc,
|
||||
validOptionsDesc,
|
||||
)
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.User,
|
||||
Content: correctionContent,
|
||||
Extra: map[string]any{
|
||||
correctionHistoryKindKey: correctionHistoryKindCorrectionUser,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// appendCorrectionAssistantIfNeeded 在纠错回灌前做最小降噪。
|
||||
//
|
||||
// 1. 空文本直接跳过,避免写入“占位噪音”;
|
||||
// 2. 若与“最近一条 assistant 文本”完全一致则跳过,避免同句反复回灌;
|
||||
// 3. 仅负责“是否回灌”判定,不负责生成纠错 user 提示。
|
||||
func appendCorrectionAssistantIfNeeded(
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
assistantContent string,
|
||||
) {
|
||||
if conversationContext == nil {
|
||||
return
|
||||
}
|
||||
assistantContent = strings.TrimSpace(assistantContent)
|
||||
if assistantContent == "" {
|
||||
return
|
||||
}
|
||||
|
||||
history := conversationContext.HistorySnapshot()
|
||||
for i := len(history) - 1; i >= 0; i-- {
|
||||
msg := history[i]
|
||||
if msg == nil || msg.Role != schema.Assistant {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(msg.Content) == assistantContent {
|
||||
return
|
||||
}
|
||||
// 只看最近一条 assistant,避免误去重很久以前的正常重复表达。
|
||||
break
|
||||
}
|
||||
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: assistantContent,
|
||||
})
|
||||
}
|
||||
276
backend/services/agent/node/deliver.go
Normal file
276
backend/services/agent/node/deliver.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentprompt "github.com/LoveLosita/smartflow/backend/services/agent/prompt"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
)
|
||||
|
||||
const (
|
||||
deliverStageName = "deliver"
|
||||
deliverStatusBlockID = "deliver.status"
|
||||
deliverSpeakBlockID = "deliver.speak"
|
||||
)
|
||||
|
||||
// DeliverNodeInput 描述交付节点单轮运行所需的最小依赖。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责生成交付总结并推送给用户,不负责后续流程推进;
|
||||
// 2. RuntimeState 提供计划步骤和执行状态;
|
||||
// 3. ConversationContext 提供执行阶段的对话历史;
|
||||
// 4. 交付完成后标记流程结束。
|
||||
type DeliverNodeInput struct {
|
||||
RuntimeState *agentmodel.AgentRuntimeState
|
||||
ConversationContext *agentmodel.ConversationContext
|
||||
Client *llmservice.Client
|
||||
ChunkEmitter *agentstream.ChunkEmitter
|
||||
ThinkingEnabled bool // 是否开启 thinking,由 config.yaml 的 agent.thinking.deliver 注入
|
||||
CompactionStore agentmodel.CompactionStore // 上下文压缩持久化
|
||||
PersistVisibleMessage agentmodel.PersistVisibleMessageFunc
|
||||
}
|
||||
|
||||
// RunDeliverNode 执行一轮交付节点逻辑。
|
||||
//
|
||||
// 核心职责:
|
||||
// 1. 调 LLM 基于原始计划 + 执行历史生成交付总结;
|
||||
// 2. 伪流式推送总结给用户;
|
||||
// 3. 写入对话历史,保证上下文连续;
|
||||
// 4. 标记流程结束。
|
||||
//
|
||||
// 降级策略:
|
||||
// 1. LLM 调用失败时,回退到机械格式化总结,不中断流程;
|
||||
// 2. 机械总结包含计划步骤列表和完成进度。
|
||||
func RunDeliverNode(ctx context.Context, input DeliverNodeInput) error {
|
||||
runtimeState, conversationContext, emitter, err := prepareDeliverNodeInput(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
flowState := runtimeState.EnsureCommonState()
|
||||
|
||||
// 1. 推送交付阶段状态,让前端知道正在生成总结。
|
||||
if err := emitter.EmitStatus(
|
||||
deliverStatusBlockID,
|
||||
deliverStageName,
|
||||
"summarizing",
|
||||
"正在生成交付总结。",
|
||||
false,
|
||||
); err != nil {
|
||||
return fmt.Errorf("交付阶段状态推送失败: %w", err)
|
||||
}
|
||||
|
||||
// 2. 在线流式消息会把 execute / deliver 的正文追加到同一条 assistant 气泡。
|
||||
// 2.1 deliver 的 LLM 真流式路径不会经过 normalizeSpeak,因此第一段总结可能贴住上一段 execute 正文。
|
||||
// 2.2 这里先发一个仅用于 SSE 展示的段落分隔;不写入 history,避免历史回放和持久化消息额外多空行。
|
||||
// 2.3 若本轮 deliver 前没有任何正文,前端 Markdown 渲染会 trim 掉开头空行,不影响首段展示。
|
||||
if err := emitter.EmitAssistantText(deliverSpeakBlockID, deliverStageName, "\n\n", false); err != nil {
|
||||
return fmt.Errorf("交付总结段落分隔推送失败: %w", err)
|
||||
}
|
||||
|
||||
// 3. 调 LLM 生成交付总结。
|
||||
summary, streamed := generateDeliverSummary(ctx, input.Client, flowState, conversationContext, input.ThinkingEnabled, input.CompactionStore, emitter)
|
||||
|
||||
// 3.1 排程完毕卡片信号:
|
||||
// 1. 仅在流程正常完成且确实产生过日程变更(粗排或写工具)时推送;
|
||||
// 2. 前端收到 kind=schedule_completed 后,自行用对话 ID 调用现有接口拉取排程数据渲染卡片;
|
||||
// 3. 不携带 Redis key 或排程数据,保持信号职责单一。
|
||||
if flowState.IsCompleted() && flowState.HasScheduleChanges {
|
||||
_ = emitter.EmitScheduleCompleted(deliverStatusBlockID, deliverStageName)
|
||||
}
|
||||
|
||||
// 4. 推送总结。LLM 路径已在 generateDeliverSummary 内部真流式推送,
|
||||
// 仅机械/降级路径需要在此伪流式补推。
|
||||
if strings.TrimSpace(summary) != "" {
|
||||
if !streamed {
|
||||
msg := schema.AssistantMessage(summary, nil)
|
||||
if err := emitter.EmitPseudoAssistantText(
|
||||
ctx,
|
||||
deliverSpeakBlockID,
|
||||
deliverStageName,
|
||||
summary,
|
||||
agentstream.DefaultPseudoStreamOptions(),
|
||||
); err != nil {
|
||||
return fmt.Errorf("交付总结推送失败: %w", err)
|
||||
}
|
||||
conversationContext.AppendHistory(msg)
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
} else {
|
||||
msg := schema.AssistantMessage(summary, nil)
|
||||
conversationContext.AppendHistory(msg)
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 推送最终完成状态。
|
||||
_ = emitter.EmitStatus(
|
||||
deliverStatusBlockID,
|
||||
deliverStageName,
|
||||
"done",
|
||||
"本轮流程已结束。",
|
||||
true,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateDeliverSummary 尝试调用 LLM 生成交付总结,失败时降级到机械格式化。
|
||||
//
|
||||
// 返回值:
|
||||
// - summary:完整总结文本(用于历史写入);
|
||||
// - streamed:true 表示文本已通过 EmitStreamAssistantText 真流式推送到前端,调用方无需再伪流式。
|
||||
func generateDeliverSummary(
|
||||
ctx context.Context,
|
||||
client *llmservice.Client,
|
||||
flowState *agentmodel.CommonState,
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
thinkingEnabled bool,
|
||||
compactionStore agentmodel.CompactionStore,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
) (string, bool) {
|
||||
if flowState != nil {
|
||||
switch {
|
||||
case flowState.IsAborted():
|
||||
return normalizeSpeak(buildAbortSummary(flowState)), false
|
||||
case flowState.IsExhaustedTerminal():
|
||||
return normalizeSpeak(buildExhaustedSummary(flowState)), false
|
||||
}
|
||||
}
|
||||
|
||||
if client == nil {
|
||||
return buildMechanicalSummary(flowState), false
|
||||
}
|
||||
|
||||
messages := agentprompt.BuildDeliverMessages(flowState, conversationContext)
|
||||
messages = compactUnifiedMessagesIfNeeded(ctx, messages, UnifiedCompactInput{
|
||||
Client: client,
|
||||
CompactionStore: compactionStore,
|
||||
FlowState: flowState,
|
||||
Emitter: emitter,
|
||||
StageName: deliverStageName,
|
||||
StatusBlockID: deliverStatusBlockID,
|
||||
})
|
||||
logNodeLLMContext(deliverStageName, "summarizing", flowState, messages)
|
||||
|
||||
reader, err := client.Stream(
|
||||
ctx,
|
||||
messages,
|
||||
llmservice.GenerateOptions{
|
||||
Temperature: 0.5,
|
||||
MaxTokens: 800,
|
||||
Thinking: resolveThinkingMode(thinkingEnabled),
|
||||
Metadata: map[string]any{
|
||||
"stage": deliverStageName,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] deliver Stream 调用失败,降级到机械总结: %v", err)
|
||||
return buildMechanicalSummary(flowState), false
|
||||
}
|
||||
|
||||
fullText, streamErr := emitter.EmitStreamAssistantText(ctx, reader, deliverSpeakBlockID, deliverStageName)
|
||||
if streamErr != nil || strings.TrimSpace(fullText) == "" {
|
||||
log.Printf("[WARN] deliver 流式推送失败或结果为空,降级到机械总结: streamErr=%v textLen=%d", streamErr, len(fullText))
|
||||
return buildMechanicalSummary(flowState), false
|
||||
}
|
||||
|
||||
return normalizeSpeak(fullText), true
|
||||
}
|
||||
|
||||
// buildAbortSummary 生成“流程已终止”的统一交付文案。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 第二轮开始,abort 的用户可见文案由终止方提前写入 CommonState;
|
||||
// 2. deliver 不再重新猜测或改写业务异常,只做最终收口;
|
||||
// 3. 若历史快照缺失 user_message,则回退到一份通用说明,避免前端收到空白结果。
|
||||
func buildAbortSummary(state *agentmodel.CommonState) string {
|
||||
if state == nil || state.TerminalOutcome == nil {
|
||||
return "本轮流程已终止。"
|
||||
}
|
||||
if msg := strings.TrimSpace(state.TerminalOutcome.UserMessage); msg != "" {
|
||||
return msg
|
||||
}
|
||||
return "本轮流程已终止,请根据当前提示检查后再继续。"
|
||||
}
|
||||
|
||||
// buildExhaustedSummary 生成“轮次耗尽”的统一收口文案。
|
||||
func buildExhaustedSummary(state *agentmodel.CommonState) string {
|
||||
if state == nil {
|
||||
return "本轮执行已达到安全轮次上限,当前先停止继续操作。"
|
||||
}
|
||||
|
||||
prefix := "本轮执行已达到安全轮次上限,当前先停止继续操作。"
|
||||
if state.TerminalOutcome != nil && strings.TrimSpace(state.TerminalOutcome.UserMessage) != "" {
|
||||
prefix = strings.TrimSpace(state.TerminalOutcome.UserMessage)
|
||||
}
|
||||
if !state.HasPlan() {
|
||||
return prefix
|
||||
}
|
||||
return prefix + "\n\n" + strings.TrimSpace(buildMechanicalSummary(state))
|
||||
}
|
||||
|
||||
// buildMechanicalSummary 在 LLM 不可用时,机械拼接一份最小可用总结。
|
||||
func buildMechanicalSummary(state *agentmodel.CommonState) string {
|
||||
if state == nil {
|
||||
return "任务流程已结束。"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
current, total := state.PlanProgress()
|
||||
|
||||
if !state.HasPlan() {
|
||||
return "任务流程已结束。"
|
||||
}
|
||||
|
||||
if state.IsExhaustedTerminal() {
|
||||
sb.WriteString(fmt.Sprintf("任务因执行轮次耗尽提前结束,已完成 %d/%d 步。\n", current, total))
|
||||
} else {
|
||||
sb.WriteString("所有计划步骤已执行完毕。\n")
|
||||
}
|
||||
|
||||
sb.WriteString("\n执行情况:\n")
|
||||
for i, step := range state.PlanSteps {
|
||||
marker := "[ ]"
|
||||
if i < current {
|
||||
marker = "[x]"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s %s\n", marker, strings.TrimSpace(step.Content)))
|
||||
}
|
||||
|
||||
if state.IsExhaustedTerminal() && current < total {
|
||||
sb.WriteString("\n如需继续完成剩余步骤,可以告诉我继续。")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// prepareDeliverNodeInput 校验并准备交付节点的运行态依赖。
|
||||
func prepareDeliverNodeInput(input DeliverNodeInput) (
|
||||
*agentmodel.AgentRuntimeState,
|
||||
*agentmodel.ConversationContext,
|
||||
*agentstream.ChunkEmitter,
|
||||
error,
|
||||
) {
|
||||
if input.RuntimeState == nil {
|
||||
return nil, nil, nil, fmt.Errorf("deliver node: runtime state 不能为空")
|
||||
}
|
||||
|
||||
input.RuntimeState.EnsureCommonState()
|
||||
if input.ConversationContext == nil {
|
||||
input.ConversationContext = agentmodel.NewConversationContext("")
|
||||
}
|
||||
if input.ChunkEmitter == nil {
|
||||
input.ChunkEmitter = agentstream.NewChunkEmitter(
|
||||
agentstream.NoopPayloadEmitter(), "", "", time.Now().Unix(),
|
||||
)
|
||||
}
|
||||
return input.RuntimeState, input.ConversationContext, input.ChunkEmitter, nil
|
||||
}
|
||||
14
backend/services/agent/node/execute.go
Normal file
14
backend/services/agent/node/execute.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
agentexecute "github.com/LoveLosita/smartflow/backend/services/agent/node/execute"
|
||||
)
|
||||
|
||||
type ExecuteNodeInput = agentexecute.ExecuteNodeInput
|
||||
type ExecuteRoundObservation = agentexecute.ExecuteRoundObservation
|
||||
|
||||
func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
||||
return agentexecute.RunExecuteNode(ctx, input)
|
||||
}
|
||||
522
backend/services/agent/node/execute/action_router.go
Normal file
522
backend/services/agent/node/execute/action_router.go
Normal file
@@ -0,0 +1,522 @@
|
||||
package agentexecute
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
agentshared "github.com/LoveLosita/smartflow/backend/services/agent/shared"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentrouter "github.com/LoveLosita/smartflow/backend/services/agent/router"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
agenttools "github.com/LoveLosita/smartflow/backend/services/agent/tools"
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type executeDecisionStreamOutput struct {
|
||||
decision *agentmodel.ExecuteDecision
|
||||
rawText string
|
||||
parsedBeforeText string
|
||||
parsedAfterText string
|
||||
streamedSpeak string
|
||||
speakStreamed bool
|
||||
firstChunk bool
|
||||
}
|
||||
|
||||
func collectExecuteDecisionFromLLM(
|
||||
ctx context.Context,
|
||||
input ExecuteNodeInput,
|
||||
flowState *agentmodel.CommonState,
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
messages []*schema.Message,
|
||||
) (*executeDecisionStreamOutput, error) {
|
||||
reader, err := input.Client.Stream(
|
||||
ctx,
|
||||
messages,
|
||||
llmservice.GenerateOptions{
|
||||
Temperature: 1.0,
|
||||
MaxTokens: 131072,
|
||||
Thinking: agentshared.ResolveThinkingMode(input.ThinkingEnabled),
|
||||
Metadata: map[string]any{
|
||||
"stage": executeStageName,
|
||||
"step_index": flowState.CurrentStep,
|
||||
"round_used": flowState.RoundUsed,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("执行阶段 Stream 请求失败: %w", err)
|
||||
}
|
||||
|
||||
parser := agentrouter.NewStreamDecisionParser()
|
||||
output := &executeDecisionStreamOutput{firstChunk: true}
|
||||
var fullText strings.Builder
|
||||
reasoningDigestor, digestorErr := emitter.NewReasoningDigestor(ctx, executeSpeakBlockID, executeStageName)
|
||||
if digestorErr != nil {
|
||||
return nil, fmt.Errorf("执行 thinking 摘要器初始化失败: %w", digestorErr)
|
||||
}
|
||||
defer func() {
|
||||
if reasoningDigestor != nil {
|
||||
_ = reasoningDigestor.Close(ctx)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
chunk, recvErr := reader.Recv()
|
||||
if recvErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if recvErr != nil {
|
||||
log.Printf("[WARN] execute stream recv error chat=%s err=%v", flowState.ConversationID, recvErr)
|
||||
break
|
||||
}
|
||||
|
||||
if chunk != nil && strings.TrimSpace(chunk.ReasoningContent) != "" {
|
||||
if reasoningDigestor != nil {
|
||||
reasoningDigestor.Append(chunk.ReasoningContent)
|
||||
}
|
||||
}
|
||||
|
||||
content := ""
|
||||
if chunk != nil {
|
||||
content = chunk.Content
|
||||
}
|
||||
|
||||
visible, ready, _ := parser.Feed(content)
|
||||
if !ready {
|
||||
continue
|
||||
}
|
||||
|
||||
result := parser.Result()
|
||||
output.rawText = result.RawBuffer
|
||||
output.parsedBeforeText = result.BeforeText
|
||||
output.parsedAfterText = result.AfterText
|
||||
|
||||
if result.Fallback || result.ParseFailed {
|
||||
log.Printf(
|
||||
"[DEBUG] execute LLM 决策解析失败 chat=%s round=%d raw=%s",
|
||||
flowState.ConversationID,
|
||||
flowState.RoundUsed,
|
||||
output.rawText,
|
||||
)
|
||||
flowState.ConsecutiveCorrections++
|
||||
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||
return nil, fmt.Errorf(
|
||||
"连续 %d 次解析决策 JSON 失败,终止执行。原始输出=%s",
|
||||
flowState.ConsecutiveCorrections,
|
||||
output.rawText,
|
||||
)
|
||||
}
|
||||
|
||||
errorDesc := "未识别到合法的 SMARTFLOW_DECISION 标签,无法继续解析。"
|
||||
optionHint := "请输出一个 <SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>,然后再在标签外补充可见文本。"
|
||||
if strings.Contains(output.rawText, `"tool_call": [`) || strings.Contains(output.rawText, `"tool_call":[`) {
|
||||
errorDesc = "检测到 tool_call 字段被错误写成数组;每次只允许调用一个工具,不支持数组形式。"
|
||||
optionHint = "请把多次工具调用拆开,每次只保留一个 tool_call,然后再继续下一轮。"
|
||||
}
|
||||
agentshared.AppendLLMCorrectionWithHint(conversationContext, output.rawText, errorDesc, optionHint)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
decision, parseErr := llmservice.ParseJSONObject[agentmodel.ExecuteDecision](result.DecisionJSON)
|
||||
if parseErr != nil {
|
||||
log.Printf(
|
||||
"[DEBUG] execute LLM JSON 解析失败 chat=%s round=%d json=%s raw=%s",
|
||||
flowState.ConversationID,
|
||||
flowState.RoundUsed,
|
||||
result.DecisionJSON,
|
||||
output.rawText,
|
||||
)
|
||||
flowState.ConsecutiveCorrections++
|
||||
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||
return nil, fmt.Errorf(
|
||||
"连续 %d 次解析决策 JSON 失败,终止执行。原始输出=%s",
|
||||
flowState.ConsecutiveCorrections,
|
||||
output.rawText,
|
||||
)
|
||||
}
|
||||
agentshared.AppendLLMCorrectionWithHint(
|
||||
conversationContext,
|
||||
"",
|
||||
"决策标签内的 JSON 格式不合法。",
|
||||
"请确保 <SMARTFLOW_DECISION> 标签内是合法 JSON;当 action=next_plan/done 时,goal_check 必须是字符串(不要输出对象)。",
|
||||
)
|
||||
return nil, nil
|
||||
}
|
||||
output.decision = decision
|
||||
|
||||
if visible != "" {
|
||||
if reasoningDigestor != nil {
|
||||
reasoningDigestor.MarkContentStarted()
|
||||
}
|
||||
if emitErr := emitter.EmitAssistantText(
|
||||
executeSpeakBlockID,
|
||||
executeStageName,
|
||||
visible,
|
||||
output.firstChunk,
|
||||
); emitErr != nil {
|
||||
return nil, fmt.Errorf("执行回答推送失败: %w", emitErr)
|
||||
}
|
||||
output.speakStreamed = true
|
||||
fullText.WriteString(visible)
|
||||
output.firstChunk = false
|
||||
}
|
||||
|
||||
for {
|
||||
chunk2, recvErr2 := reader.Recv()
|
||||
if recvErr2 == io.EOF {
|
||||
break
|
||||
}
|
||||
if recvErr2 != nil {
|
||||
log.Printf("[WARN] execute speak stream error chat=%s err=%v", flowState.ConversationID, recvErr2)
|
||||
break
|
||||
}
|
||||
if chunk2 == nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(chunk2.ReasoningContent) != "" {
|
||||
if reasoningDigestor != nil {
|
||||
reasoningDigestor.Append(chunk2.ReasoningContent)
|
||||
}
|
||||
}
|
||||
if chunk2.Content != "" {
|
||||
if reasoningDigestor != nil {
|
||||
reasoningDigestor.MarkContentStarted()
|
||||
}
|
||||
if emitErr := emitter.EmitAssistantText(
|
||||
executeSpeakBlockID,
|
||||
executeStageName,
|
||||
chunk2.Content,
|
||||
output.firstChunk,
|
||||
); emitErr != nil {
|
||||
return nil, fmt.Errorf("执行回答推送失败: %w", emitErr)
|
||||
}
|
||||
output.speakStreamed = true
|
||||
fullText.WriteString(chunk2.Content)
|
||||
output.firstChunk = false
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if output.decision == nil {
|
||||
if strings.TrimSpace(output.rawText) == "" {
|
||||
log.Printf(
|
||||
"[WARN] execute LLM 返回空文本 chat=%s round=%d consecutive=%d/%d",
|
||||
flowState.ConversationID,
|
||||
flowState.RoundUsed,
|
||||
flowState.ConsecutiveCorrections+1,
|
||||
maxConsecutiveCorrections,
|
||||
)
|
||||
flowState.ConsecutiveCorrections++
|
||||
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||
return nil, fmt.Errorf("连续 %d 次模型返回空文本,终止执行", flowState.ConsecutiveCorrections)
|
||||
}
|
||||
agentshared.AppendLLMCorrectionWithHint(
|
||||
conversationContext,
|
||||
"",
|
||||
"模型没有返回任何内容。",
|
||||
"请至少返回一个 <SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION> 形式的执行决策。",
|
||||
)
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("执行阶段模型输出中未提取到决策标签")
|
||||
}
|
||||
|
||||
output.streamedSpeak = fullText.String()
|
||||
output.decision.Speak = pickExecuteVisibleSpeak(
|
||||
output.streamedSpeak,
|
||||
output.parsedAfterText,
|
||||
output.parsedBeforeText,
|
||||
output.decision,
|
||||
)
|
||||
log.Printf(
|
||||
"[DEBUG] execute LLM 响应 chat=%s round=%d action=%s speak_len=%d raw_len=%d raw_preview=%.200s",
|
||||
flowState.ConversationID,
|
||||
flowState.RoundUsed,
|
||||
output.decision.Action,
|
||||
len(output.decision.Speak),
|
||||
len(output.rawText),
|
||||
output.rawText,
|
||||
)
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func handleExecuteDecision(
|
||||
ctx context.Context,
|
||||
input ExecuteNodeInput,
|
||||
runtimeState *agentmodel.AgentRuntimeState,
|
||||
flowState *agentmodel.CommonState,
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
output *executeDecisionStreamOutput,
|
||||
) error {
|
||||
if output == nil || output.decision == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
decision := output.decision
|
||||
if decision.Action == agentmodel.ExecuteActionDone &&
|
||||
decision.ToolCall != nil &&
|
||||
strings.EqualFold(strings.TrimSpace(decision.ToolCall.Name), agenttools.ToolNameContextToolsRemove) {
|
||||
decision.ToolCall = nil
|
||||
}
|
||||
|
||||
if err := decision.Validate(); err != nil {
|
||||
flowState.ConsecutiveCorrections++
|
||||
log.Printf(
|
||||
"[WARN] execute 决策不合法 chat=%s round=%d consecutive=%d/%d err=%s",
|
||||
flowState.ConversationID,
|
||||
flowState.RoundUsed,
|
||||
flowState.ConsecutiveCorrections,
|
||||
maxConsecutiveCorrections,
|
||||
err.Error(),
|
||||
)
|
||||
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||
return fmt.Errorf(
|
||||
"连续 %d 次决策不合法,终止执行。%s (原始输出: %s)",
|
||||
flowState.ConsecutiveCorrections,
|
||||
err.Error(),
|
||||
output.rawText,
|
||||
)
|
||||
}
|
||||
_ = emitter.EmitStatus(
|
||||
executeStatusBlockID,
|
||||
executeStageName,
|
||||
"executing",
|
||||
fmt.Sprintf("执行校验:决策不合法:%s,已请求模型重试。", err.Error()),
|
||||
false,
|
||||
)
|
||||
agentshared.AppendLLMCorrectionWithHint(
|
||||
conversationContext,
|
||||
"",
|
||||
fmt.Sprintf("本次执行决策不合法:%s", err.Error()),
|
||||
"合法的 action 包括:continue(继续当前步骤)、ask_user(追问用户)、confirm(写操作确认)、next_plan(推进到下一步)、done(任务完成)、abort(正式终止本轮流程)。",
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
flowState.ConsecutiveCorrections = 0
|
||||
decision.Speak = pickExecuteVisibleSpeak(
|
||||
decision.Speak,
|
||||
output.parsedAfterText,
|
||||
output.parsedBeforeText,
|
||||
decision,
|
||||
)
|
||||
decision.Speak = normalizeSpeak(decision.Speak)
|
||||
|
||||
if decision.Action == agentmodel.ExecuteActionConfirm &&
|
||||
decision.ToolCall != nil &&
|
||||
input.ToolRegistry != nil &&
|
||||
!input.ToolRegistry.IsWriteTool(decision.ToolCall.Name) {
|
||||
decision.Action = agentmodel.ExecuteActionContinue
|
||||
}
|
||||
|
||||
if decision.Action == agentmodel.ExecuteActionContinue &&
|
||||
decision.ToolCall != nil &&
|
||||
agenttools.IsContextManagementTool(decision.ToolCall.Name) {
|
||||
decision.Speak = ""
|
||||
}
|
||||
|
||||
if !output.speakStreamed && strings.TrimSpace(decision.Speak) != "" {
|
||||
if emitErr := emitter.EmitAssistantText(
|
||||
executeSpeakBlockID,
|
||||
executeStageName,
|
||||
decision.Speak,
|
||||
output.firstChunk,
|
||||
); emitErr != nil {
|
||||
return fmt.Errorf("执行回答补发失败: %w", emitErr)
|
||||
}
|
||||
output.speakStreamed = true
|
||||
output.firstChunk = false
|
||||
}
|
||||
|
||||
if output.speakStreamed {
|
||||
if tail := buildExecuteNormalizedSpeakTail(output.streamedSpeak, decision.Speak); tail != "" {
|
||||
if emitErr := emitter.EmitAssistantText(
|
||||
executeSpeakBlockID,
|
||||
executeStageName,
|
||||
tail,
|
||||
output.firstChunk,
|
||||
); emitErr != nil {
|
||||
return fmt.Errorf("执行回答尾段补发失败: %w", emitErr)
|
||||
}
|
||||
output.firstChunk = false
|
||||
}
|
||||
}
|
||||
|
||||
if flowState.HasPlan() &&
|
||||
(decision.Action == agentmodel.ExecuteActionNextPlan ||
|
||||
decision.Action == agentmodel.ExecuteActionDone) {
|
||||
if strings.TrimSpace(decision.GoalCheck) == "" {
|
||||
flowState.ConsecutiveCorrections++
|
||||
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||
return fmt.Errorf("连续 %d 次 goal_check 为空,终止执行", flowState.ConsecutiveCorrections)
|
||||
}
|
||||
_ = emitter.EmitStatus(
|
||||
executeStatusBlockID,
|
||||
executeStageName,
|
||||
"executing",
|
||||
fmt.Sprintf("执行校验:action=%s 缺少 goal_check,已请求模型重试。", decision.Action),
|
||||
false,
|
||||
)
|
||||
agentshared.AppendLLMCorrectionWithHint(
|
||||
conversationContext,
|
||||
"",
|
||||
fmt.Sprintf("你输出了 action=%s,但 goal_check 为空。", decision.Action),
|
||||
fmt.Sprintf("输出 %s 时,必须在 goal_check 中对照 done_when 逐条说明完成依据。", decision.Action),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
askUserHistoryAppended := false
|
||||
if strings.TrimSpace(decision.Speak) != "" {
|
||||
isConfirmWithCard := decision.Action == agentmodel.ExecuteActionConfirm && !input.AlwaysExecute
|
||||
isAskUser := decision.Action == agentmodel.ExecuteActionAskUser
|
||||
isAbort := decision.Action == agentmodel.ExecuteActionAbort
|
||||
|
||||
if !isConfirmWithCard && !isAskUser && !isAbort {
|
||||
msg := schema.AssistantMessage(decision.Speak, nil)
|
||||
agentshared.PersistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
}
|
||||
if !isAbort {
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: decision.Speak,
|
||||
})
|
||||
if isAskUser {
|
||||
askUserHistoryAppended = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch decision.Action {
|
||||
case agentmodel.ExecuteActionContinue:
|
||||
if decision.ToolCall != nil {
|
||||
if input.ToolRegistry != nil && input.ToolRegistry.IsWriteTool(decision.ToolCall.Name) {
|
||||
flowState.ConsecutiveCorrections++
|
||||
log.Printf(
|
||||
"[WARN] execute 决策协议违背 chat=%s round=%d action=continue tool=%s consecutive=%d/%d",
|
||||
flowState.ConversationID,
|
||||
flowState.RoundUsed,
|
||||
strings.TrimSpace(decision.ToolCall.Name),
|
||||
flowState.ConsecutiveCorrections,
|
||||
maxConsecutiveCorrections,
|
||||
)
|
||||
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||
return fmt.Errorf("连续 %d 次输出 continue+写工具,终止执行", flowState.ConsecutiveCorrections)
|
||||
}
|
||||
_ = emitter.EmitStatus(
|
||||
executeStatusBlockID,
|
||||
executeStageName,
|
||||
"executing",
|
||||
fmt.Sprintf(
|
||||
"执行校验:写工具 %q 未执行。原因:模型输出了 action=continue;所有写工具都必须使用 action=confirm。",
|
||||
strings.TrimSpace(decision.ToolCall.Name),
|
||||
),
|
||||
false,
|
||||
)
|
||||
llmOutput := decision.Speak
|
||||
if strings.TrimSpace(llmOutput) == "" {
|
||||
llmOutput = decision.Reason
|
||||
}
|
||||
agentshared.AppendLLMCorrectionWithHint(
|
||||
conversationContext,
|
||||
llmOutput,
|
||||
fmt.Sprintf("你输出了 action=continue,但同时提供了 %q 这个写工具。", decision.ToolCall.Name),
|
||||
"所有写工具都必须使用 action=confirm,并放在同一个 tool_call 中;continue 仅用于读工具。如果写操作尚未执行,请直接回发 confirm。",
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if shouldForceFeasibilityNegotiation(flowState, input.ToolRegistry, decision.ToolCall.Name) {
|
||||
runtimeState.OpenAskUserInteraction(
|
||||
uuid.NewString(),
|
||||
buildInfeasibleNegotiationQuestion(flowState),
|
||||
strings.TrimSpace(input.ResumeNode),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
return executeToolCall(
|
||||
ctx,
|
||||
flowState,
|
||||
conversationContext,
|
||||
decision.ToolCall,
|
||||
emitter,
|
||||
input.ToolRegistry,
|
||||
input.ScheduleState,
|
||||
input.WriteSchedulePreview,
|
||||
)
|
||||
}
|
||||
if strings.TrimSpace(decision.Speak) == "" && strings.TrimSpace(decision.Reason) != "" {
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: decision.Reason,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
|
||||
case agentmodel.ExecuteActionAskUser:
|
||||
question := resolveExecuteAskUserText(decision)
|
||||
runtimeState.OpenAskUserInteraction(uuid.NewString(), question, strings.TrimSpace(input.ResumeNode))
|
||||
runtimeState.SetPendingInteractionMetadata(agentmodel.PendingMetaAskUserSpeakStreamed, output.speakStreamed)
|
||||
runtimeState.SetPendingInteractionMetadata(agentmodel.PendingMetaAskUserHistoryAppended, askUserHistoryAppended)
|
||||
return nil
|
||||
|
||||
case agentmodel.ExecuteActionConfirm:
|
||||
if decision.ToolCall != nil && shouldForceFeasibilityNegotiation(flowState, input.ToolRegistry, decision.ToolCall.Name) {
|
||||
runtimeState.OpenAskUserInteraction(
|
||||
uuid.NewString(),
|
||||
buildInfeasibleNegotiationQuestion(flowState),
|
||||
strings.TrimSpace(input.ResumeNode),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if input.AlwaysExecute && decision.ToolCall != nil {
|
||||
return executeToolCall(
|
||||
ctx,
|
||||
flowState,
|
||||
conversationContext,
|
||||
decision.ToolCall,
|
||||
emitter,
|
||||
input.ToolRegistry,
|
||||
input.ScheduleState,
|
||||
input.WriteSchedulePreview,
|
||||
)
|
||||
}
|
||||
return handleExecuteActionConfirm(decision, runtimeState, flowState)
|
||||
|
||||
case agentmodel.ExecuteActionNextPlan:
|
||||
if !flowState.AdvanceStep() {
|
||||
flowState.Done()
|
||||
}
|
||||
appendExecuteStepAdvancedMarker(conversationContext)
|
||||
syncExecutePinnedContext(conversationContext, flowState)
|
||||
return nil
|
||||
|
||||
case agentmodel.ExecuteActionDone:
|
||||
flowState.Done()
|
||||
return nil
|
||||
|
||||
case agentmodel.ExecuteActionAbort:
|
||||
return handleExecuteActionAbort(decision, flowState)
|
||||
|
||||
default:
|
||||
llmOutput := decision.Speak
|
||||
if strings.TrimSpace(llmOutput) == "" {
|
||||
llmOutput = decision.Reason
|
||||
}
|
||||
agentshared.AppendLLMCorrectionWithHint(
|
||||
conversationContext,
|
||||
llmOutput,
|
||||
fmt.Sprintf("你输出的 action %q 不是合法的执行动作。", decision.Action),
|
||||
"合法的 action 包括:continue(继续当前步骤)、ask_user(追问用户)、confirm(写操作确认)、next_plan(推进到下一步)、done(任务完成)、abort(正式终止本轮流程)。",
|
||||
)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
119
backend/services/agent/node/execute/action_text.go
Normal file
119
backend/services/agent/node/execute/action_text.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package agentexecute
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
)
|
||||
|
||||
func resolveExecuteAskUserText(decision *agentmodel.ExecuteDecision) string {
|
||||
if decision == nil {
|
||||
return "执行过程中遇到不确定的情况,需要向你确认。"
|
||||
}
|
||||
if strings.TrimSpace(decision.Speak) != "" {
|
||||
return strings.TrimSpace(decision.Speak)
|
||||
}
|
||||
if strings.TrimSpace(decision.Reason) != "" {
|
||||
return strings.TrimSpace(decision.Reason)
|
||||
}
|
||||
return "执行过程中遇到不确定的情况,需要向你确认。"
|
||||
}
|
||||
|
||||
func pickExecuteVisibleSpeak(
|
||||
streamed string,
|
||||
afterText string,
|
||||
beforeText string,
|
||||
decision *agentmodel.ExecuteDecision,
|
||||
) string {
|
||||
if text := strings.TrimSpace(streamed); text != "" {
|
||||
return text
|
||||
}
|
||||
if text := strings.TrimSpace(afterText); text != "" {
|
||||
return text
|
||||
}
|
||||
if text := strings.TrimSpace(beforeText); text != "" {
|
||||
return text
|
||||
}
|
||||
return buildExecuteSpeakWithFallback(decision)
|
||||
}
|
||||
|
||||
func buildExecuteSpeakWithFallback(decision *agentmodel.ExecuteDecision) string {
|
||||
if decision == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
speak := strings.TrimSpace(decision.Speak)
|
||||
if speak != "" {
|
||||
return speak
|
||||
}
|
||||
|
||||
switch decision.Action {
|
||||
case agentmodel.ExecuteActionContinue,
|
||||
agentmodel.ExecuteActionAskUser,
|
||||
agentmodel.ExecuteActionConfirm:
|
||||
if reason := strings.TrimSpace(decision.Reason); reason != "" {
|
||||
return reason
|
||||
}
|
||||
switch decision.Action {
|
||||
case agentmodel.ExecuteActionAskUser:
|
||||
return "我还缺少一条关键信息,想先向你确认。"
|
||||
case agentmodel.ExecuteActionConfirm:
|
||||
return "我先整理好这一步操作,等待你的确认。"
|
||||
default:
|
||||
return "我先继续这一步处理,马上给你结果。"
|
||||
}
|
||||
default:
|
||||
return speak
|
||||
}
|
||||
}
|
||||
|
||||
func handleExecuteActionConfirm(
|
||||
decision *agentmodel.ExecuteDecision,
|
||||
runtimeState *agentmodel.AgentRuntimeState,
|
||||
flowState *agentmodel.CommonState,
|
||||
) error {
|
||||
toolCall := decision.ToolCall
|
||||
|
||||
argsJSON := ""
|
||||
if toolCall.Arguments != nil {
|
||||
if raw, err := json.Marshal(toolCall.Arguments); err == nil {
|
||||
argsJSON = string(raw)
|
||||
}
|
||||
}
|
||||
|
||||
runtimeState.PendingConfirmTool = &agentmodel.PendingToolCallSnapshot{
|
||||
ToolName: toolCall.Name,
|
||||
ArgsJSON: argsJSON,
|
||||
Summary: strings.TrimSpace(decision.Speak),
|
||||
}
|
||||
|
||||
flowState.Phase = agentmodel.PhaseWaitingConfirm
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleExecuteActionAbort(
|
||||
decision *agentmodel.ExecuteDecision,
|
||||
flowState *agentmodel.CommonState,
|
||||
) error {
|
||||
if decision == nil || decision.Abort == nil {
|
||||
return fmt.Errorf("abort 动作缺少终止信息")
|
||||
}
|
||||
if flowState == nil {
|
||||
return fmt.Errorf("abort 动作缺少流程状态")
|
||||
}
|
||||
|
||||
internalReason := strings.TrimSpace(decision.Abort.InternalReason)
|
||||
if internalReason == "" {
|
||||
internalReason = strings.TrimSpace(decision.Reason)
|
||||
}
|
||||
|
||||
flowState.Abort(
|
||||
executeStageName,
|
||||
decision.Abort.Code,
|
||||
decision.Abort.UserMessage,
|
||||
internalReason,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
162
backend/services/agent/node/execute/args.go
Normal file
162
backend/services/agent/node/execute/args.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package agentexecute
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func intSliceToSet(values []int) map[int]struct{} {
|
||||
result := make(map[int]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
result[value] = struct{}{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func readIntAnyFromMap(args map[string]any, keys ...string) (int, bool) {
|
||||
for _, key := range keys {
|
||||
if args == nil {
|
||||
continue
|
||||
}
|
||||
raw, exists := args[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if value, ok := parseAnyToInt(raw); ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func readIntSliceAnyFromMap(args map[string]any, keys ...string) []int {
|
||||
for _, key := range keys {
|
||||
if args == nil {
|
||||
continue
|
||||
}
|
||||
raw, exists := args[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
values := parseAnyToIntSlice(raw)
|
||||
if len(values) > 0 {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readStringAnyFromMap(args map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if args == nil {
|
||||
continue
|
||||
}
|
||||
raw, exists := args[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if text, ok := raw.(string); ok {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseAnyToInt(value any) (int, bool) {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return v, true
|
||||
case int8:
|
||||
return int(v), true
|
||||
case int16:
|
||||
return int(v), true
|
||||
case int32:
|
||||
return int(v), true
|
||||
case int64:
|
||||
return int(v), true
|
||||
case float32:
|
||||
return int(v), true
|
||||
case float64:
|
||||
return int(v), true
|
||||
case json.Number:
|
||||
if iv, err := v.Int64(); err == nil {
|
||||
return int(iv), true
|
||||
}
|
||||
if fv, err := v.Float64(); err == nil {
|
||||
return int(fv), true
|
||||
}
|
||||
case string:
|
||||
text := strings.TrimSpace(v)
|
||||
if text == "" {
|
||||
return 0, false
|
||||
}
|
||||
iv, err := strconv.Atoi(text)
|
||||
if err == nil {
|
||||
return iv, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func parseAnyToIntSlice(value any) []int {
|
||||
switch values := value.(type) {
|
||||
case []int:
|
||||
result := make([]int, 0, len(values))
|
||||
for _, value := range values {
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
case []any:
|
||||
result := make([]int, 0, len(values))
|
||||
for _, item := range values {
|
||||
iv, ok := parseAnyToInt(item)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
result = append(result, iv)
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func parseAnyToStringSlice(value any) []string {
|
||||
switch values := value.(type) {
|
||||
case []string:
|
||||
result := make([]string, 0, len(values))
|
||||
for _, item := range values {
|
||||
text := strings.TrimSpace(item)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, text)
|
||||
}
|
||||
return result
|
||||
case []any:
|
||||
result := make([]string, 0, len(values))
|
||||
for _, item := range values {
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", item))
|
||||
if text == "" || text == "<nil>" {
|
||||
continue
|
||||
}
|
||||
result = append(result, text)
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func truncateText(text string, maxLen int) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if len(text) <= maxLen {
|
||||
return text
|
||||
}
|
||||
if maxLen <= 3 {
|
||||
return text[:maxLen]
|
||||
}
|
||||
return text[:maxLen-3] + "..."
|
||||
}
|
||||
157
backend/services/agent/node/execute/context.go
Normal file
157
backend/services/agent/node/execute/context.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package agentexecute
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
planCurrentStepKey = "current_step"
|
||||
planCurrentStepTitle = "当前步骤"
|
||||
)
|
||||
|
||||
func prepareExecuteNodeInput(input ExecuteNodeInput) (*agentmodel.AgentRuntimeState, *agentmodel.ConversationContext, *agentstream.ChunkEmitter, error) {
|
||||
if input.RuntimeState == nil {
|
||||
return nil, nil, nil, fmt.Errorf("execute node: runtime state 不能为空")
|
||||
}
|
||||
if input.Client == nil {
|
||||
return nil, nil, nil, fmt.Errorf("execute node: execute client 未注入")
|
||||
}
|
||||
|
||||
input.RuntimeState.EnsureCommonState()
|
||||
if input.ConversationContext == nil {
|
||||
input.ConversationContext = agentmodel.NewConversationContext("")
|
||||
}
|
||||
if input.ChunkEmitter == nil {
|
||||
input.ChunkEmitter = agentstream.NewChunkEmitter(agentstream.NoopPayloadEmitter(), "", "", time.Now().Unix())
|
||||
}
|
||||
return input.RuntimeState, input.ConversationContext, input.ChunkEmitter, nil
|
||||
}
|
||||
|
||||
func syncExecutePinnedContext(
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
flowState *agentmodel.CommonState,
|
||||
) {
|
||||
if conversationContext == nil || flowState == nil {
|
||||
return
|
||||
}
|
||||
|
||||
execContent := buildExecuteContextPinnedMarkdown(flowState)
|
||||
if strings.TrimSpace(execContent) != "" {
|
||||
conversationContext.UpsertPinnedBlock(agentmodel.ContextBlock{
|
||||
Key: executePinnedKey,
|
||||
Title: "执行上下文",
|
||||
Content: execContent,
|
||||
})
|
||||
}
|
||||
|
||||
if !flowState.HasPlan() {
|
||||
conversationContext.RemovePinnedBlock(planCurrentStepKey)
|
||||
return
|
||||
}
|
||||
|
||||
step, ok := flowState.CurrentPlanStep()
|
||||
if !ok {
|
||||
conversationContext.RemovePinnedBlock(planCurrentStepKey)
|
||||
return
|
||||
}
|
||||
|
||||
current, total := flowState.PlanProgress()
|
||||
title := strings.TrimSpace(planCurrentStepTitle)
|
||||
if title == "" {
|
||||
title = "当前步骤"
|
||||
}
|
||||
conversationContext.UpsertPinnedBlock(agentmodel.ContextBlock{
|
||||
Key: planCurrentStepKey,
|
||||
Title: title,
|
||||
Content: buildCurrentPlanStepPinnedMarkdown(step, current, total),
|
||||
})
|
||||
}
|
||||
|
||||
func appendExecuteStepAdvancedMarker(conversationContext *agentmodel.ConversationContext) {
|
||||
if conversationContext == nil {
|
||||
return
|
||||
}
|
||||
|
||||
history := conversationContext.HistorySnapshot()
|
||||
if len(history) > 0 {
|
||||
last := history[len(history)-1]
|
||||
if last != nil && last.Extra != nil {
|
||||
if kind, ok := last.Extra[executeHistoryKindKey].(string); ok && strings.TrimSpace(kind) == executeHistoryKindStepAdvanced {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: "",
|
||||
Extra: map[string]any{
|
||||
executeHistoryKindKey: executeHistoryKindStepAdvanced,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func buildExecuteContextPinnedMarkdown(flowState *agentmodel.CommonState) string {
|
||||
if flowState == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := make([]string, 0, 8)
|
||||
if flowState.HasPlan() {
|
||||
lines = append(lines, "执行模式:计划执行(按步骤推进)")
|
||||
current, total := flowState.PlanProgress()
|
||||
lines = append(lines, fmt.Sprintf("计划进度:第 %d/%d 步", current, total))
|
||||
|
||||
if step, ok := flowState.CurrentPlanStep(); ok {
|
||||
lines = append(lines, "当前步骤:"+compactExecutePinnedText(step.Content))
|
||||
doneWhen := compactExecutePinnedText(step.DoneWhen)
|
||||
if doneWhen != "" {
|
||||
lines = append(lines, "完成判定(done_when):"+doneWhen)
|
||||
}
|
||||
lines = append(lines, "动作纪律:未满足 done_when 禁止 next_plan;满足后优先 next_plan。")
|
||||
} else {
|
||||
lines = append(lines, "当前步骤:不可读(可能已执行完成)")
|
||||
}
|
||||
} else {
|
||||
lines = append(lines, "执行模式:自由执行(无预定义步骤)")
|
||||
}
|
||||
|
||||
if flowState.MaxRounds > 0 {
|
||||
lines = append(lines, fmt.Sprintf("轮次预算:%d/%d", flowState.RoundUsed, flowState.MaxRounds))
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func buildCurrentPlanStepPinnedMarkdown(step agentmodel.PlanStep, current, total int) string {
|
||||
lines := make([]string, 0, 4)
|
||||
lines = append(lines, fmt.Sprintf("步骤进度:第 %d/%d 步", current, total))
|
||||
|
||||
content := compactExecutePinnedText(step.Content)
|
||||
if content == "" {
|
||||
content = "(空)"
|
||||
}
|
||||
lines = append(lines, "步骤内容:"+content)
|
||||
|
||||
doneWhen := compactExecutePinnedText(step.DoneWhen)
|
||||
if doneWhen != "" {
|
||||
lines = append(lines, "完成判定:"+doneWhen)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func compactExecutePinnedText(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
text = strings.ReplaceAll(text, "\r\n", "\n")
|
||||
text = strings.ReplaceAll(text, "\n", ";")
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
150
backend/services/agent/node/execute/run.go
Normal file
150
backend/services/agent/node/execute/run.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package agentexecute
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
agentshared "github.com/LoveLosita/smartflow/backend/services/agent/shared"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentprompt "github.com/LoveLosita/smartflow/backend/services/agent/prompt"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
agenttools "github.com/LoveLosita/smartflow/backend/services/agent/tools"
|
||||
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
)
|
||||
|
||||
const (
|
||||
executeStageName = "execute"
|
||||
executeStatusBlockID = "execute.status"
|
||||
executeSpeakBlockID = "execute.speak"
|
||||
executePinnedKey = "execution_context"
|
||||
toolAnalyzeHealth = "analyze_health"
|
||||
executeHistoryKindKey = "newagent_history_kind"
|
||||
executeHistoryKindStepAdvanced = "execute_step_advanced"
|
||||
|
||||
maxConsecutiveCorrections = 3
|
||||
)
|
||||
|
||||
type ExecuteNodeInput struct {
|
||||
RuntimeState *agentmodel.AgentRuntimeState
|
||||
ConversationContext *agentmodel.ConversationContext
|
||||
UserInput string
|
||||
Client *llmservice.Client
|
||||
ChunkEmitter *agentstream.ChunkEmitter
|
||||
ResumeNode string
|
||||
ToolRegistry *agenttools.ToolRegistry
|
||||
ScheduleState *schedule.ScheduleState
|
||||
CompactionStore agentmodel.CompactionStore
|
||||
WriteSchedulePreview agentmodel.WriteSchedulePreviewFunc
|
||||
OriginalScheduleState *schedule.ScheduleState
|
||||
AlwaysExecute bool
|
||||
ThinkingEnabled bool
|
||||
PersistVisibleMessage agentmodel.PersistVisibleMessageFunc
|
||||
}
|
||||
|
||||
type ExecuteRoundObservation struct {
|
||||
Round int `json:"round"`
|
||||
StepIndex int `json:"step_index"`
|
||||
GoalCheck string `json:"goal_check,omitempty"`
|
||||
Decision string `json:"decision,omitempty"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
ToolParams string `json:"tool_params,omitempty"`
|
||||
ToolSuccess bool `json:"tool_success"`
|
||||
ToolResult string `json:"tool_result,omitempty"`
|
||||
}
|
||||
|
||||
func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
||||
runtimeState, conversationContext, emitter, err := prepareExecuteNodeInput(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
flowState := runtimeState.EnsureCommonState()
|
||||
applyPendingContextHook(flowState)
|
||||
|
||||
if runtimeState.PendingConfirmTool != nil {
|
||||
return executePendingTool(
|
||||
ctx,
|
||||
runtimeState,
|
||||
conversationContext,
|
||||
input.ToolRegistry,
|
||||
input.ScheduleState,
|
||||
input.OriginalScheduleState,
|
||||
input.WriteSchedulePreview,
|
||||
emitter,
|
||||
)
|
||||
}
|
||||
|
||||
if input.ScheduleState != nil && flowState.RoundUsed == 0 {
|
||||
schedule.ResetTaskProcessingQueue(input.ScheduleState)
|
||||
}
|
||||
|
||||
syncExecutePinnedContext(conversationContext, flowState)
|
||||
|
||||
if flowState.HasCurrentPlanStep() {
|
||||
current, total := flowState.PlanProgress()
|
||||
currentStep, _ := flowState.CurrentPlanStep()
|
||||
if err := emitter.EmitStatus(
|
||||
executeStatusBlockID,
|
||||
executeStageName,
|
||||
"executing",
|
||||
fmt.Sprintf("正在执行第 %d/%d 步:%s", current, total, truncateText(currentStep.Content, 60)),
|
||||
false,
|
||||
); err != nil {
|
||||
return fmt.Errorf("执行阶段状态推送失败: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := emitter.EmitStatus(
|
||||
executeStatusBlockID,
|
||||
executeStageName,
|
||||
"executing",
|
||||
"正在处理你的请求...",
|
||||
false,
|
||||
); err != nil {
|
||||
return fmt.Errorf("执行阶段状态推送失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !flowState.NextRound() {
|
||||
flowState.Exhaust(
|
||||
executeStageName,
|
||||
"本轮执行已达到安全轮次上限,当前先停止继续操作。如需继续,我可以在你确认后接着处理剩余步骤。",
|
||||
"execute rounds exhausted before task completion",
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
messages := agentprompt.BuildExecuteMessages(flowState, conversationContext)
|
||||
messages = agentshared.CompactUnifiedMessagesIfNeeded(ctx, messages, agentshared.UnifiedCompactInput{
|
||||
Client: input.Client,
|
||||
CompactionStore: input.CompactionStore,
|
||||
FlowState: flowState,
|
||||
Emitter: emitter,
|
||||
StageName: executeStageName,
|
||||
StatusBlockID: executeStatusBlockID,
|
||||
})
|
||||
|
||||
agentshared.LogNodeLLMContext(executeStageName, "decision", flowState, messages)
|
||||
|
||||
decisionOutput, err := collectExecuteDecisionFromLLM(
|
||||
ctx,
|
||||
input,
|
||||
flowState,
|
||||
conversationContext,
|
||||
emitter,
|
||||
messages,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return handleExecuteDecision(
|
||||
ctx,
|
||||
input,
|
||||
runtimeState,
|
||||
flowState,
|
||||
conversationContext,
|
||||
emitter,
|
||||
decisionOutput,
|
||||
)
|
||||
}
|
||||
332
backend/services/agent/node/execute/state_snapshot.go
Normal file
332
backend/services/agent/node/execute/state_snapshot.go
Normal file
@@ -0,0 +1,332 @@
|
||||
package agentexecute
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agenttools "github.com/LoveLosita/smartflow/backend/services/agent/tools"
|
||||
)
|
||||
|
||||
func shouldForceFeasibilityNegotiation(
|
||||
flowState *agentmodel.CommonState,
|
||||
registry *agenttools.ToolRegistry,
|
||||
toolName string,
|
||||
) bool {
|
||||
if flowState == nil || registry == nil {
|
||||
return false
|
||||
}
|
||||
if !flowState.HealthCheckDone || flowState.HealthIsFeasible {
|
||||
return false
|
||||
}
|
||||
if !registry.IsWriteTool(toolName) || !registry.RequiresScheduleState(toolName) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func buildInfeasibleNegotiationQuestion(flowState *agentmodel.CommonState) string {
|
||||
capacityGap := 0
|
||||
reasonCode := "capacity_insufficient"
|
||||
if flowState != nil {
|
||||
capacityGap = flowState.HealthCapacityGap
|
||||
if strings.TrimSpace(flowState.HealthReasonCode) != "" {
|
||||
reasonCode = strings.TrimSpace(flowState.HealthReasonCode)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"当前计划不可行:analyze_health 判断当前约束不可行(capacity_gap=%d,reason=%s)。在继续写操作前,请先与用户协商:扩展时间窗、放宽约束、缩减范围或预算,或接受风险收口。",
|
||||
capacityGap,
|
||||
reasonCode,
|
||||
)
|
||||
}
|
||||
|
||||
func buildInfeasibleBlockedResult(flowState *agentmodel.CommonState) string {
|
||||
capacityGap := 0
|
||||
reasonCode := "capacity_insufficient"
|
||||
if flowState != nil {
|
||||
capacityGap = flowState.HealthCapacityGap
|
||||
if strings.TrimSpace(flowState.HealthReasonCode) != "" {
|
||||
reasonCode = strings.TrimSpace(flowState.HealthReasonCode)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"已阻断本次写操作:analyze_health 判定当前约束不可行(capacity_gap=%d,reason=%s)。请先与用户协商:扩展时间窗 / 放宽约束 / 缩减范围或预算 / 接受风险收口。",
|
||||
capacityGap,
|
||||
reasonCode,
|
||||
)
|
||||
}
|
||||
|
||||
type contextToolsResultEnvelope struct {
|
||||
Tool string `json:"tool"`
|
||||
Success bool `json:"success"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Packs []string `json:"packs,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
All bool `json:"all,omitempty"`
|
||||
}
|
||||
|
||||
type analyzeHealthResultEnvelope struct {
|
||||
Tool string `json:"tool"`
|
||||
Success bool `json:"success"`
|
||||
Feasibility *analyzeHealthFeasibilityBrief `json:"feasibility,omitempty"`
|
||||
Decision *analyzeHealthDecisionBrief `json:"decision,omitempty"`
|
||||
}
|
||||
|
||||
type analyzeHealthFeasibilityBrief struct {
|
||||
IsFeasible bool `json:"is_feasible"`
|
||||
CapacityGap int `json:"capacity_gap"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
}
|
||||
|
||||
type analyzeHealthDecisionBrief struct {
|
||||
ShouldContinueOptimize bool `json:"should_continue_optimize"`
|
||||
PrimaryProblem string `json:"primary_problem,omitempty"`
|
||||
RecommendedOperation string `json:"recommended_operation,omitempty"`
|
||||
IsForcedImperfection bool `json:"is_forced_imperfection"`
|
||||
ImprovementSignal string `json:"improvement_signal,omitempty"`
|
||||
}
|
||||
|
||||
type upsertTaskClassResultEnvelope struct {
|
||||
Tool string `json:"tool"`
|
||||
Success bool `json:"success"`
|
||||
Validation *upsertTaskClassValidationPart `json:"validation,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
}
|
||||
|
||||
type upsertTaskClassValidationPart struct {
|
||||
OK bool `json:"ok"`
|
||||
Issues []string `json:"issues"`
|
||||
}
|
||||
|
||||
func updateActiveToolDomainSnapshot(flowState *agentmodel.CommonState, toolName string, result string) {
|
||||
if flowState == nil || !agenttools.IsContextManagementTool(toolName) {
|
||||
return
|
||||
}
|
||||
|
||||
var envelope contextToolsResultEnvelope
|
||||
if err := json.Unmarshal([]byte(result), &envelope); err != nil {
|
||||
return
|
||||
}
|
||||
if !envelope.Success {
|
||||
return
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(toolName) {
|
||||
case agenttools.ToolNameContextToolsAdd:
|
||||
domain := agenttools.NormalizeToolDomain(envelope.Domain)
|
||||
if domain == "" {
|
||||
return
|
||||
}
|
||||
nextPacks := agenttools.ResolveEffectiveToolPacks(domain, envelope.Packs)
|
||||
mode := strings.ToLower(strings.TrimSpace(envelope.Mode))
|
||||
if mode == "merge" && agenttools.NormalizeToolDomain(flowState.ActiveToolDomain) == domain {
|
||||
merged := make([]string, 0, len(flowState.ActiveToolPacks)+len(nextPacks))
|
||||
seen := make(map[string]struct{}, len(flowState.ActiveToolPacks)+len(nextPacks))
|
||||
current := agenttools.ResolveEffectiveToolPacks(domain, flowState.ActiveToolPacks)
|
||||
for _, pack := range current {
|
||||
if _, exists := seen[pack]; exists {
|
||||
continue
|
||||
}
|
||||
seen[pack] = struct{}{}
|
||||
merged = append(merged, pack)
|
||||
}
|
||||
for _, pack := range nextPacks {
|
||||
if _, exists := seen[pack]; exists {
|
||||
continue
|
||||
}
|
||||
seen[pack] = struct{}{}
|
||||
merged = append(merged, pack)
|
||||
}
|
||||
nextPacks = merged
|
||||
}
|
||||
flowState.ActiveToolDomain = domain
|
||||
flowState.ActiveToolPacks = nextPacks
|
||||
case agenttools.ToolNameContextToolsRemove:
|
||||
if envelope.All {
|
||||
flowState.ActiveToolDomain = ""
|
||||
flowState.ActiveToolPacks = nil
|
||||
return
|
||||
}
|
||||
domain := agenttools.NormalizeToolDomain(envelope.Domain)
|
||||
if domain == "" {
|
||||
return
|
||||
}
|
||||
currentDomain := agenttools.NormalizeToolDomain(flowState.ActiveToolDomain)
|
||||
if currentDomain != domain {
|
||||
return
|
||||
}
|
||||
|
||||
removedPacks := agenttools.NormalizeToolPacks(domain, envelope.Packs)
|
||||
if len(removedPacks) == 0 {
|
||||
flowState.ActiveToolDomain = ""
|
||||
flowState.ActiveToolPacks = nil
|
||||
return
|
||||
}
|
||||
|
||||
currentEffective := agenttools.ResolveEffectiveToolPacks(domain, flowState.ActiveToolPacks)
|
||||
if len(currentEffective) == 0 {
|
||||
flowState.ActiveToolDomain = ""
|
||||
flowState.ActiveToolPacks = nil
|
||||
return
|
||||
}
|
||||
|
||||
removedSet := make(map[string]struct{}, len(removedPacks))
|
||||
for _, pack := range removedPacks {
|
||||
removedSet[pack] = struct{}{}
|
||||
}
|
||||
remaining := make([]string, 0, len(currentEffective))
|
||||
for _, pack := range currentEffective {
|
||||
if _, shouldRemove := removedSet[pack]; shouldRemove {
|
||||
continue
|
||||
}
|
||||
remaining = append(remaining, pack)
|
||||
}
|
||||
if len(remaining) == 0 {
|
||||
flowState.ActiveToolDomain = ""
|
||||
flowState.ActiveToolPacks = nil
|
||||
return
|
||||
}
|
||||
flowState.ActiveToolPacks = remaining
|
||||
}
|
||||
}
|
||||
|
||||
func updateHealthFeasibilitySnapshot(flowState *agentmodel.CommonState, toolName string, result string) {
|
||||
if flowState == nil || !strings.EqualFold(strings.TrimSpace(toolName), toolAnalyzeHealth) {
|
||||
return
|
||||
}
|
||||
|
||||
flowState.HealthCheckDone = false
|
||||
flowState.HealthIsFeasible = true
|
||||
flowState.HealthCapacityGap = 0
|
||||
flowState.HealthReasonCode = ""
|
||||
|
||||
var envelope analyzeHealthResultEnvelope
|
||||
if err := json.Unmarshal([]byte(result), &envelope); err != nil {
|
||||
return
|
||||
}
|
||||
if !envelope.Success || envelope.Feasibility == nil {
|
||||
return
|
||||
}
|
||||
|
||||
flowState.HealthCheckDone = true
|
||||
flowState.HealthIsFeasible = envelope.Feasibility.IsFeasible
|
||||
flowState.HealthCapacityGap = envelope.Feasibility.CapacityGap
|
||||
flowState.HealthReasonCode = strings.TrimSpace(envelope.Feasibility.ReasonCode)
|
||||
}
|
||||
|
||||
func updateTaskClassUpsertSnapshot(flowState *agentmodel.CommonState, toolName string, result string) {
|
||||
if flowState == nil || !strings.EqualFold(strings.TrimSpace(toolName), "upsert_task_class") {
|
||||
return
|
||||
}
|
||||
|
||||
flowState.TaskClassUpsertLastTried = true
|
||||
flowState.TaskClassUpsertLastSuccess = false
|
||||
flowState.TaskClassUpsertLastIssues = nil
|
||||
|
||||
var envelope upsertTaskClassResultEnvelope
|
||||
if err := json.Unmarshal([]byte(result), &envelope); err != nil {
|
||||
flowState.TaskClassUpsertConsecutiveFailures++
|
||||
return
|
||||
}
|
||||
|
||||
success := envelope.Success
|
||||
issues := make([]string, 0)
|
||||
if envelope.Validation != nil {
|
||||
issues = append(issues, parseAnyToStringSlice(any(envelope.Validation.Issues))...)
|
||||
if !envelope.Validation.OK {
|
||||
success = false
|
||||
}
|
||||
}
|
||||
if !success && strings.TrimSpace(envelope.Error) != "" && len(issues) == 0 {
|
||||
issues = append(issues, strings.TrimSpace(envelope.Error))
|
||||
}
|
||||
issues = uniqueNonEmptyStrings(issues)
|
||||
|
||||
flowState.TaskClassUpsertLastSuccess = success
|
||||
flowState.TaskClassUpsertLastIssues = issues
|
||||
if success {
|
||||
flowState.TaskClassUpsertConsecutiveFailures = 0
|
||||
return
|
||||
}
|
||||
flowState.TaskClassUpsertConsecutiveFailures++
|
||||
}
|
||||
|
||||
func uniqueNonEmptyStrings(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
text := strings.TrimSpace(value)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[text]; exists {
|
||||
continue
|
||||
}
|
||||
seen[text] = struct{}{}
|
||||
result = append(result, text)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func updateHealthSnapshotV2(flowState *agentmodel.CommonState, toolName string, result string) {
|
||||
if flowState == nil || !strings.EqualFold(strings.TrimSpace(toolName), toolAnalyzeHealth) {
|
||||
return
|
||||
}
|
||||
|
||||
prevSignal := strings.TrimSpace(flowState.HealthImprovementSignal)
|
||||
flowState.HealthCheckDone = false
|
||||
flowState.HealthIsFeasible = true
|
||||
flowState.HealthCapacityGap = 0
|
||||
flowState.HealthReasonCode = ""
|
||||
flowState.HealthShouldContinueOptimize = false
|
||||
flowState.HealthTightnessLevel = ""
|
||||
flowState.HealthPrimaryProblem = ""
|
||||
flowState.HealthRecommendedOperation = ""
|
||||
flowState.HealthIsForcedImperfection = false
|
||||
flowState.HealthImprovementSignal = ""
|
||||
|
||||
var envelope struct {
|
||||
Success bool `json:"success"`
|
||||
Feasibility *analyzeHealthFeasibilityBrief `json:"feasibility,omitempty"`
|
||||
Metrics struct {
|
||||
Tightness *struct {
|
||||
TightnessLevel string `json:"tightness_level"`
|
||||
} `json:"tightness,omitempty"`
|
||||
} `json:"metrics"`
|
||||
Decision *analyzeHealthDecisionBrief `json:"decision,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(result), &envelope); err != nil {
|
||||
flowState.HealthStagnationCount = 0
|
||||
return
|
||||
}
|
||||
if !envelope.Success || envelope.Feasibility == nil {
|
||||
flowState.HealthStagnationCount = 0
|
||||
return
|
||||
}
|
||||
|
||||
flowState.HealthCheckDone = true
|
||||
flowState.HealthIsFeasible = envelope.Feasibility.IsFeasible
|
||||
flowState.HealthCapacityGap = envelope.Feasibility.CapacityGap
|
||||
flowState.HealthReasonCode = strings.TrimSpace(envelope.Feasibility.ReasonCode)
|
||||
if envelope.Metrics.Tightness != nil {
|
||||
flowState.HealthTightnessLevel = strings.TrimSpace(envelope.Metrics.Tightness.TightnessLevel)
|
||||
}
|
||||
if envelope.Decision != nil {
|
||||
flowState.HealthShouldContinueOptimize = envelope.Decision.ShouldContinueOptimize
|
||||
flowState.HealthPrimaryProblem = strings.TrimSpace(envelope.Decision.PrimaryProblem)
|
||||
flowState.HealthRecommendedOperation = strings.TrimSpace(envelope.Decision.RecommendedOperation)
|
||||
flowState.HealthIsForcedImperfection = envelope.Decision.IsForcedImperfection
|
||||
flowState.HealthImprovementSignal = strings.TrimSpace(envelope.Decision.ImprovementSignal)
|
||||
}
|
||||
if signal := strings.TrimSpace(flowState.HealthImprovementSignal); signal != "" && prevSignal != "" && signal == prevSignal {
|
||||
flowState.HealthStagnationCount++
|
||||
return
|
||||
}
|
||||
flowState.HealthStagnationCount = 0
|
||||
}
|
||||
418
backend/services/agent/node/execute/tool_runtime.go
Normal file
418
backend/services/agent/node/execute/tool_runtime.go
Normal file
@@ -0,0 +1,418 @@
|
||||
package agentexecute
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
agentshared "github.com/LoveLosita/smartflow/backend/services/agent/shared"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
agenttools "github.com/LoveLosita/smartflow/backend/services/agent/tools"
|
||||
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func appendToolCallResultHistory(
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
toolName string,
|
||||
args map[string]any,
|
||||
result agenttools.ToolExecutionResult,
|
||||
) {
|
||||
if conversationContext == nil {
|
||||
return
|
||||
}
|
||||
|
||||
argsJSON := "{}"
|
||||
if args != nil {
|
||||
if raw, err := json.Marshal(args); err == nil {
|
||||
argsJSON = string(raw)
|
||||
}
|
||||
}
|
||||
toolCallID := uuid.NewString()
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: "",
|
||||
ToolCalls: []schema.ToolCall{
|
||||
{
|
||||
ID: toolCallID,
|
||||
Type: "function",
|
||||
Function: schema.FunctionCall{
|
||||
Name: toolName,
|
||||
Arguments: argsJSON,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Tool,
|
||||
Content: result.ObservationText,
|
||||
ToolCallID: toolCallID,
|
||||
ToolName: toolName,
|
||||
})
|
||||
}
|
||||
|
||||
func executeToolCall(
|
||||
ctx context.Context,
|
||||
flowState *agentmodel.CommonState,
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
toolCall *agentmodel.ToolCallIntent,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
registry *agenttools.ToolRegistry,
|
||||
scheduleState *schedule.ScheduleState,
|
||||
writePreview agentmodel.WriteSchedulePreviewFunc,
|
||||
) error {
|
||||
if toolCall == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
toolName := strings.TrimSpace(toolCall.Name)
|
||||
if toolName == "" {
|
||||
return fmt.Errorf("工具调用缺少工具名称")
|
||||
}
|
||||
|
||||
if err := emitter.EmitToolCallStart(
|
||||
executeStatusBlockID,
|
||||
executeStageName,
|
||||
toolName,
|
||||
buildToolCallStartSummary(toolName, toolCall.Arguments),
|
||||
buildToolArgumentsPreviewCN(toolCall.Arguments),
|
||||
false,
|
||||
); err != nil {
|
||||
return fmt.Errorf("工具调用开始事件发送失败: %w", err)
|
||||
}
|
||||
|
||||
if registry == nil {
|
||||
return fmt.Errorf("工具注册表未注入")
|
||||
}
|
||||
if scheduleState == nil && registry.RequiresScheduleState(toolName) {
|
||||
return fmt.Errorf("日程状态未加载,无法执行工具 %q", toolName)
|
||||
}
|
||||
if registry.IsToolTemporarilyDisabled(toolName) {
|
||||
flowState.ConsecutiveCorrections++
|
||||
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||
return fmt.Errorf("连续 %d 次调用临时禁用工具,终止执行: %s",
|
||||
flowState.ConsecutiveCorrections, toolName)
|
||||
}
|
||||
blockedText := buildTemporarilyDisabledToolResult(toolName)
|
||||
blockedResult := agenttools.BlockedResult(toolName, toolCall.Arguments, blockedText, "tool_temporarily_disabled", blockedText)
|
||||
emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, blockedResult, toolCall.Arguments)
|
||||
appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, blockedResult)
|
||||
agentshared.AppendLLMCorrectionWithHint(
|
||||
conversationContext,
|
||||
"",
|
||||
fmt.Sprintf("工具 %q 当前暂时禁用。", toolName),
|
||||
"请改用 move/swap/batch_move/unplace 等排程微调工具继续推进。",
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if !registry.HasTool(toolName) {
|
||||
flowState.ConsecutiveCorrections++
|
||||
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||
return fmt.Errorf("连续 %d 次调用未知工具,终止执行: %s;可用工具:%s。",
|
||||
flowState.ConsecutiveCorrections, toolName, strings.Join(registry.ToolNames(), "、"))
|
||||
}
|
||||
log.Printf("[WARN] execute 工具名不合法 chat=%s round=%d tool=%s consecutive=%d/%d available=%v",
|
||||
flowState.ConversationID, flowState.RoundUsed, toolName,
|
||||
flowState.ConsecutiveCorrections, maxConsecutiveCorrections, registry.ToolNames())
|
||||
agentshared.AppendLLMCorrectionWithHint(
|
||||
conversationContext,
|
||||
"",
|
||||
fmt.Sprintf("你调用的工具 %q 不存在。", toolName),
|
||||
fmt.Sprintf("可用工具:%s。请检查拼写后重试。", strings.Join(registry.ToolNames(), "、")),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if !isToolVisibleForCurrentExecuteMode(flowState, registry, toolName) {
|
||||
flowState.ConsecutiveCorrections++
|
||||
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||
return fmt.Errorf("连续 %d 次调用未激活工具,终止执行: %s(active_domain=%q active_packs=%v)",
|
||||
flowState.ConsecutiveCorrections,
|
||||
toolName,
|
||||
flowState.ActiveToolDomain,
|
||||
agenttools.ResolveEffectiveToolPacks(flowState.ActiveToolDomain, flowState.ActiveToolPacks))
|
||||
}
|
||||
|
||||
addHint := `请先调用 context_tools_add 激活目标工具域后再继续。`
|
||||
if flowState != nil && flowState.ActiveOptimizeOnly {
|
||||
addHint = `当前处于“粗排后主动优化专用模式”,只允许使用 analyze_health、move、swap;不要再尝试 query_target_tasks / query_available_slots 等全窗搜索工具。`
|
||||
} else if domain, pack, ok := agenttools.ResolveToolDomainPack(toolName); ok {
|
||||
if agenttools.IsFixedToolPack(domain, pack) {
|
||||
addHint = fmt.Sprintf(`请先调用 context_tools_add,参数 domain="%s"。`, domain)
|
||||
} else {
|
||||
addHint = fmt.Sprintf(`请先调用 context_tools_add,参数 domain="%s", packs=["%s"]。`, domain, pack)
|
||||
}
|
||||
}
|
||||
|
||||
agentshared.AppendLLMCorrectionWithHint(
|
||||
conversationContext,
|
||||
"",
|
||||
fmt.Sprintf("你调用的工具 %q 当前不在已激活工具域内。", toolName),
|
||||
addHint,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
if shouldForceFeasibilityNegotiation(flowState, registry, toolName) {
|
||||
blockedText := buildInfeasibleBlockedResult(flowState)
|
||||
blockedResult := agenttools.BlockedResult(toolName, toolCall.Arguments, blockedText, "health_negotiation_required", blockedText)
|
||||
emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, blockedResult, toolCall.Arguments)
|
||||
appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, blockedResult)
|
||||
return nil
|
||||
}
|
||||
|
||||
beforeDigest := summarizeScheduleStateForDebug(scheduleState)
|
||||
if !registry.RequiresScheduleState(toolName) {
|
||||
if toolCall.Arguments == nil {
|
||||
toolCall.Arguments = make(map[string]any)
|
||||
}
|
||||
toolCall.Arguments["_user_id"] = flowState.UserID
|
||||
}
|
||||
result := registry.Execute(scheduleState, toolName, toolCall.Arguments)
|
||||
result = agenttools.EnsureToolResultDefaults(result, toolCall.Arguments)
|
||||
updateHealthSnapshotV2(flowState, toolName, result.ObservationText)
|
||||
updateTaskClassUpsertSnapshot(flowState, toolName, result.ObservationText)
|
||||
updateActiveToolDomainSnapshot(flowState, toolName, result.ObservationText)
|
||||
afterDigest := summarizeScheduleStateForDebug(scheduleState)
|
||||
log.Printf(
|
||||
"[DEBUG] execute tool chat=%s round=%d tool=%s args=%s before=%s after=%s result_preview=%.200s",
|
||||
flowState.ConversationID,
|
||||
flowState.RoundUsed,
|
||||
toolName,
|
||||
marshalArgsForDebug(toolCall.Arguments),
|
||||
beforeDigest,
|
||||
afterDigest,
|
||||
flattenForLog(result.ObservationText),
|
||||
)
|
||||
emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, result, toolCall.Arguments)
|
||||
|
||||
appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, result)
|
||||
|
||||
if registry.IsScheduleMutationTool(toolName) {
|
||||
flowState.HasScheduleWriteOps = true
|
||||
flowState.HasScheduleChanges = true
|
||||
}
|
||||
|
||||
tryWritePreviewAfterWriteTool(ctx, flowState, scheduleState, registry, toolName, writePreview)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyPendingContextHook(flowState *agentmodel.CommonState) {
|
||||
if flowState == nil || flowState.PendingContextHook == nil {
|
||||
return
|
||||
}
|
||||
hook := flowState.PendingContextHook
|
||||
domain := agenttools.NormalizeToolDomain(hook.Domain)
|
||||
if domain == "" {
|
||||
flowState.PendingContextHook = nil
|
||||
return
|
||||
}
|
||||
flowState.ActiveToolDomain = domain
|
||||
flowState.ActiveToolPacks = agenttools.ResolveEffectiveToolPacks(domain, hook.Packs)
|
||||
flowState.PendingContextHook = nil
|
||||
}
|
||||
|
||||
func isToolVisibleForCurrentExecuteMode(
|
||||
flowState *agentmodel.CommonState,
|
||||
registry *agenttools.ToolRegistry,
|
||||
toolName string,
|
||||
) bool {
|
||||
if registry == nil {
|
||||
return false
|
||||
}
|
||||
activeDomain := ""
|
||||
var activePacks []string
|
||||
if flowState != nil {
|
||||
activeDomain = flowState.ActiveToolDomain
|
||||
activePacks = flowState.ActiveToolPacks
|
||||
}
|
||||
if !registry.IsToolVisibleInDomain(activeDomain, activePacks, toolName) {
|
||||
return false
|
||||
}
|
||||
if flowState != nil && flowState.ActiveOptimizeOnly && !agenttools.IsToolAllowedInActiveOptimize(toolName) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func buildTemporarilyDisabledToolResult(toolName string) string {
|
||||
return fmt.Sprintf("工具 %q 当前暂时禁用。请改用 move/swap/batch_move/unplace 等排程微调工具。", strings.TrimSpace(toolName))
|
||||
}
|
||||
|
||||
func executePendingTool(
|
||||
ctx context.Context,
|
||||
runtimeState *agentmodel.AgentRuntimeState,
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
registry *agenttools.ToolRegistry,
|
||||
scheduleState *schedule.ScheduleState,
|
||||
originalState *schedule.ScheduleState,
|
||||
writePreview agentmodel.WriteSchedulePreviewFunc,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
) error {
|
||||
pending := runtimeState.PendingConfirmTool
|
||||
if pending == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(pending.ArgsJSON), &args); err != nil {
|
||||
return fmt.Errorf("解析待确认工具参数失败: %w", err)
|
||||
}
|
||||
|
||||
if err := emitter.EmitToolCallStart(
|
||||
executeStatusBlockID,
|
||||
executeStageName,
|
||||
pending.ToolName,
|
||||
buildToolCallStartSummary(pending.ToolName, args),
|
||||
buildToolArgumentsPreviewCN(args),
|
||||
false,
|
||||
); err != nil {
|
||||
return fmt.Errorf("工具调用开始事件发送失败: %w", err)
|
||||
}
|
||||
|
||||
if scheduleState == nil {
|
||||
return fmt.Errorf("日程状态未加载,无法执行已确认的写工具 %s", pending.ToolName)
|
||||
}
|
||||
flowState := runtimeState.EnsureCommonState()
|
||||
if registry.IsToolTemporarilyDisabled(pending.ToolName) {
|
||||
blockedText := buildTemporarilyDisabledToolResult(pending.ToolName)
|
||||
blockedResult := agenttools.BlockedResult(pending.ToolName, args, blockedText, "tool_temporarily_disabled", blockedText)
|
||||
emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, blockedResult, args)
|
||||
appendToolCallResultHistory(conversationContext, pending.ToolName, args, blockedResult)
|
||||
runtimeState.PendingConfirmTool = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
if shouldForceFeasibilityNegotiation(flowState, registry, pending.ToolName) {
|
||||
blockedText := buildInfeasibleBlockedResult(flowState)
|
||||
blockedResult := agenttools.BlockedResult(pending.ToolName, args, blockedText, "health_negotiation_required", blockedText)
|
||||
emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, blockedResult, args)
|
||||
appendToolCallResultHistory(conversationContext, pending.ToolName, args, blockedResult)
|
||||
runtimeState.PendingConfirmTool = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
beforeDigest := summarizeScheduleStateForDebug(scheduleState)
|
||||
if !registry.RequiresScheduleState(pending.ToolName) {
|
||||
if args == nil {
|
||||
args = make(map[string]any)
|
||||
}
|
||||
args["_user_id"] = flowState.UserID
|
||||
}
|
||||
result := registry.Execute(scheduleState, pending.ToolName, args)
|
||||
result = agenttools.EnsureToolResultDefaults(result, args)
|
||||
updateHealthSnapshotV2(flowState, pending.ToolName, result.ObservationText)
|
||||
updateTaskClassUpsertSnapshot(flowState, pending.ToolName, result.ObservationText)
|
||||
updateActiveToolDomainSnapshot(flowState, pending.ToolName, result.ObservationText)
|
||||
afterDigest := summarizeScheduleStateForDebug(scheduleState)
|
||||
log.Printf(
|
||||
"[DEBUG] execute pending tool chat=%s round=%d tool=%s args=%s before=%s after=%s result_preview=%.200s",
|
||||
flowState.ConversationID,
|
||||
flowState.RoundUsed,
|
||||
pending.ToolName,
|
||||
marshalArgsForDebug(args),
|
||||
beforeDigest,
|
||||
afterDigest,
|
||||
flattenForLog(result.ObservationText),
|
||||
)
|
||||
emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, result, args)
|
||||
|
||||
appendToolCallResultHistory(conversationContext, pending.ToolName, args, result)
|
||||
|
||||
if registry.IsScheduleMutationTool(pending.ToolName) {
|
||||
flowState.HasScheduleWriteOps = true
|
||||
flowState.HasScheduleChanges = true
|
||||
}
|
||||
|
||||
tryWritePreviewAfterWriteTool(ctx, flowState, scheduleState, registry, pending.ToolName, writePreview)
|
||||
|
||||
runtimeState.PendingConfirmTool = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func tryWritePreviewAfterWriteTool(
|
||||
ctx context.Context,
|
||||
flowState *agentmodel.CommonState,
|
||||
scheduleState *schedule.ScheduleState,
|
||||
registry *agenttools.ToolRegistry,
|
||||
toolName string,
|
||||
writePreview agentmodel.WriteSchedulePreviewFunc,
|
||||
) {
|
||||
if flowState == nil || scheduleState == nil || registry == nil || writePreview == nil {
|
||||
return
|
||||
}
|
||||
if !registry.IsScheduleMutationTool(toolName) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := writePreview(ctx, scheduleState, flowState.UserID, flowState.ConversationID, flowState.TaskClassIDs); err != nil {
|
||||
log.Printf(
|
||||
"[WARN] execute realtime preview write failed chat=%s tool=%s err=%v",
|
||||
flowState.ConversationID,
|
||||
toolName,
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"[DEBUG] execute realtime preview write success chat=%s tool=%s",
|
||||
flowState.ConversationID,
|
||||
toolName,
|
||||
)
|
||||
}
|
||||
|
||||
var listItemRe = regexp.MustCompile(`([^\n])([2-9][\.、]\s)`)
|
||||
|
||||
func normalizeSpeak(speak string) string {
|
||||
speak = strings.TrimSpace(speak)
|
||||
if speak == "" {
|
||||
return speak
|
||||
}
|
||||
if !strings.Contains(speak, "\n") {
|
||||
speak = listItemRe.ReplaceAllString(speak, "$1\n$2")
|
||||
}
|
||||
return speak + "\n"
|
||||
}
|
||||
|
||||
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):]
|
||||
}
|
||||
|
||||
func emitToolCallResultEvent(
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
blockID string,
|
||||
stage string,
|
||||
result agenttools.ToolExecutionResult,
|
||||
args map[string]any,
|
||||
) {
|
||||
if emitter == nil {
|
||||
return
|
||||
}
|
||||
result = agenttools.EnsureToolResultDefaults(result, args)
|
||||
_ = emitter.EmitToolCallResult(
|
||||
blockID,
|
||||
stage,
|
||||
result.Tool,
|
||||
result.Status,
|
||||
result.Summary,
|
||||
result.ArgumentsPreview,
|
||||
agenttools.ToolArgumentViewToMap(result.ArgumentView),
|
||||
agenttools.ToolDisplayViewToMap(result.ResultView),
|
||||
false,
|
||||
)
|
||||
}
|
||||
420
backend/services/agent/node/execute/tool_view.go
Normal file
420
backend/services/agent/node/execute/tool_view.go
Normal file
@@ -0,0 +1,420 @@
|
||||
package agentexecute
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
)
|
||||
|
||||
func summarizeScheduleStateForDebug(state *schedule.ScheduleState) string {
|
||||
if state == nil {
|
||||
return "state=nil"
|
||||
}
|
||||
|
||||
total := len(state.Tasks)
|
||||
pendingNoSlot := 0
|
||||
suggestedTotal := 0
|
||||
existingTotal := 0
|
||||
taskItemWithSlot := 0
|
||||
eventWithSlot := 0
|
||||
|
||||
for i := range state.Tasks {
|
||||
t := &state.Tasks[i]
|
||||
hasSlot := len(t.Slots) > 0
|
||||
|
||||
switch {
|
||||
case schedule.IsPendingTask(*t):
|
||||
pendingNoSlot++
|
||||
case schedule.IsSuggestedTask(*t):
|
||||
suggestedTotal++
|
||||
case schedule.IsExistingTask(*t):
|
||||
existingTotal++
|
||||
}
|
||||
|
||||
if hasSlot {
|
||||
if t.Source == "task_item" {
|
||||
taskItemWithSlot++
|
||||
}
|
||||
if t.Source == "event" {
|
||||
eventWithSlot++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"tasks=%d pending=%d suggested=%d existing=%d task_item_with_slot=%d event_with_slot=%d",
|
||||
total,
|
||||
pendingNoSlot,
|
||||
suggestedTotal,
|
||||
existingTotal,
|
||||
taskItemWithSlot,
|
||||
eventWithSlot,
|
||||
)
|
||||
}
|
||||
|
||||
func marshalArgsForDebug(args map[string]any) string {
|
||||
if len(args) == 0 {
|
||||
return "{}"
|
||||
}
|
||||
raw, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return "<marshal_error>"
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func flattenForLog(text string) string {
|
||||
text = strings.ReplaceAll(text, "\n", " ")
|
||||
text = strings.ReplaceAll(text, "\r", " ")
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func resolveToolEventResultStatus(result string) string {
|
||||
normalized := strings.TrimSpace(result)
|
||||
if normalized == "" {
|
||||
return "done"
|
||||
}
|
||||
if strings.Contains(normalized, "失败") {
|
||||
return "failed"
|
||||
}
|
||||
lower := strings.ToLower(normalized)
|
||||
if strings.Contains(lower, "error") || strings.Contains(lower, "failed") {
|
||||
return "failed"
|
||||
}
|
||||
return "done"
|
||||
}
|
||||
|
||||
func buildToolEventResultSummary(result string) string {
|
||||
flat := flattenForLog(result)
|
||||
if flat == "" {
|
||||
return "工具已执行完成。"
|
||||
}
|
||||
|
||||
if summary, ok := tryExtractToolResultSummaryCN(flat); ok {
|
||||
return summary
|
||||
}
|
||||
|
||||
runes := []rune(flat)
|
||||
if len(runes) <= 48 {
|
||||
return flat
|
||||
}
|
||||
return string(runes[:48]) + "..."
|
||||
}
|
||||
|
||||
func tryExtractToolResultSummaryCN(raw string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
toolRaw := strings.TrimSpace(readStringAnyFromMap(payload, "tool"))
|
||||
toolName := resolveToolDisplayNameCN(toolRaw)
|
||||
|
||||
if strings.EqualFold(toolRaw, "upsert_task_class") {
|
||||
if summary, ok := buildUpsertTaskClassSummaryCN(payload); ok {
|
||||
return truncateToolSummaryCN(summary), true
|
||||
}
|
||||
}
|
||||
|
||||
if errText := strings.TrimSpace(readStringAnyFromMap(payload, "error", "err")); errText != "" {
|
||||
return truncateToolSummaryCN(fmt.Sprintf("%s失败:%s", toolName, errText)), true
|
||||
}
|
||||
|
||||
if success, exists := payload["success"]; exists {
|
||||
if ok, isBool := success.(bool); isBool && !ok {
|
||||
reason := strings.TrimSpace(readStringAnyFromMap(payload, "reason", "message"))
|
||||
if reason != "" {
|
||||
return truncateToolSummaryCN(fmt.Sprintf("%s失败:%s", toolName, reason)), true
|
||||
}
|
||||
return truncateToolSummaryCN(fmt.Sprintf("%s执行失败。", toolName)), true
|
||||
}
|
||||
}
|
||||
|
||||
if message := strings.TrimSpace(readStringAnyFromMap(payload, "result", "message", "reason")); message != "" {
|
||||
return truncateToolSummaryCN(message), true
|
||||
}
|
||||
|
||||
pending, hasPending := readIntAnyFromMap(payload, "pending_count")
|
||||
completed, hasCompleted := readIntAnyFromMap(payload, "completed_count")
|
||||
if hasPending || hasCompleted {
|
||||
skipped, _ := readIntAnyFromMap(payload, "skipped_count")
|
||||
return fmt.Sprintf("队列状态:待处理 %d,已完成 %d,已跳过 %d。", pending, completed, skipped), true
|
||||
}
|
||||
|
||||
if hasHead, exists := payload["has_head"]; exists {
|
||||
if b, isBool := hasHead.(bool); isBool {
|
||||
if b {
|
||||
return "已获取当前队首任务。", true
|
||||
}
|
||||
return "当前队列没有可处理任务。", true
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := payload["slot_candidates"]; ok {
|
||||
if total, exists := readIntAnyFromMap(payload, "total"); exists {
|
||||
return fmt.Sprintf("共找到 %d 个可用时段。", total), true
|
||||
}
|
||||
}
|
||||
|
||||
if toolRaw != "" {
|
||||
return fmt.Sprintf("已完成“%s”操作。", toolName), true
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func buildUpsertTaskClassSummaryCN(payload map[string]any) (string, bool) {
|
||||
validationRaw, hasValidation := payload["validation"]
|
||||
if !hasValidation {
|
||||
return "", false
|
||||
}
|
||||
validation, ok := validationRaw.(map[string]any)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
validationOK, hasValidationOK := validation["ok"].(bool)
|
||||
issues := parseAnyToStringSlice(validation["issues"])
|
||||
|
||||
if hasValidationOK && !validationOK {
|
||||
if len(issues) > 0 {
|
||||
return fmt.Sprintf("任务类写入未通过校验:%s。", strings.Join(issues, ";")), true
|
||||
}
|
||||
return "任务类写入未通过校验,请先补齐缺失字段。", true
|
||||
}
|
||||
|
||||
success, hasSuccess := payload["success"].(bool)
|
||||
if hasSuccess && success {
|
||||
if taskClassID, ok := readIntAnyFromMap(payload, "task_class_id"); ok && taskClassID > 0 {
|
||||
return fmt.Sprintf("任务类写入成功,task_class_id=%d。", taskClassID), true
|
||||
}
|
||||
return "任务类写入成功。", true
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func truncateToolSummaryCN(text string) string {
|
||||
runes := []rune(strings.TrimSpace(text))
|
||||
if len(runes) <= 48 {
|
||||
return string(runes)
|
||||
}
|
||||
return string(runes[:48]) + "..."
|
||||
}
|
||||
|
||||
func buildToolCallStartSummary(toolName string, args map[string]any) string {
|
||||
displayName := resolveToolDisplayNameCN(toolName)
|
||||
argSummary := buildToolArgumentsPreviewCN(args)
|
||||
if argSummary == "" {
|
||||
return fmt.Sprintf("已调用工具:%s。", displayName)
|
||||
}
|
||||
return fmt.Sprintf("已调用工具:%s(%s)。", displayName, argSummary)
|
||||
}
|
||||
|
||||
func buildToolArgumentsPreviewCN(args map[string]any) string {
|
||||
if len(args) <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
type argPair struct {
|
||||
Key string
|
||||
Label string
|
||||
}
|
||||
|
||||
orderedPairs := []argPair{
|
||||
{Key: "title", Label: "任务标题"},
|
||||
{Key: "task_name", Label: "任务名称"},
|
||||
{Key: "deadline_at", Label: "截止时间"},
|
||||
{Key: "new_day", Label: "目标日期"},
|
||||
{Key: "new_slot_start", Label: "目标开始时段"},
|
||||
{Key: "day", Label: "日期"},
|
||||
{Key: "day_start", Label: "开始日"},
|
||||
{Key: "day_end", Label: "结束日"},
|
||||
{Key: "day_scope", Label: "日期范围"},
|
||||
{Key: "day_of_week", Label: "星期"},
|
||||
{Key: "week", Label: "周"},
|
||||
{Key: "week_from", Label: "起始周"},
|
||||
{Key: "week_to", Label: "结束周"},
|
||||
{Key: "week_filter", Label: "周筛选"},
|
||||
{Key: "slot_start", Label: "开始时段"},
|
||||
{Key: "slot_end", Label: "结束时段"},
|
||||
{Key: "slot_type", Label: "时段类型"},
|
||||
{Key: "slot_types", Label: "时段类型"},
|
||||
{Key: "task_id", Label: "任务 ID"},
|
||||
{Key: "task_ids", Label: "任务 ID 列表"},
|
||||
{Key: "task_item_id", Label: "任务项 ID"},
|
||||
{Key: "task_item_ids", Label: "任务项 ID 列表"},
|
||||
{Key: "query", Label: "查询词"},
|
||||
{Key: "keyword", Label: "关键词"},
|
||||
{Key: "domain", Label: "工具域"},
|
||||
{Key: "mode", Label: "激活模式"},
|
||||
{Key: "all", Label: "移除全部"},
|
||||
{Key: "top_k", Label: "返回数量"},
|
||||
{Key: "url", Label: "链接"},
|
||||
{Key: "reason", Label: "原因"},
|
||||
{Key: "limit", Label: "数量"},
|
||||
}
|
||||
|
||||
items := make([]string, 0, 2)
|
||||
for _, pair := range orderedPairs {
|
||||
rawValue, exists := args[pair.Key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
valueText := formatToolArgValueByKeyCN(pair.Key, rawValue)
|
||||
if valueText == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, fmt.Sprintf("%s:%s", pair.Label, valueText))
|
||||
if len(items) >= 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(items, ",")
|
||||
}
|
||||
|
||||
func resolveToolDisplayNameCN(toolName string) string {
|
||||
name := strings.TrimSpace(toolName)
|
||||
if name == "" {
|
||||
return "未知工具"
|
||||
}
|
||||
|
||||
displayNameMap := map[string]string{
|
||||
"get_overview": "查看总览",
|
||||
"query_range": "查询时间范围",
|
||||
"queue_status": "查看任务队列",
|
||||
"queue_pop_head": "获取队首任务",
|
||||
"queue_apply_head_move": "应用队首任务时段",
|
||||
"queue_skip_head": "跳过队首任务",
|
||||
"query_target_tasks": "查询目标任务",
|
||||
"query_available_slots": "查询可用时段",
|
||||
"get_task_info": "查看任务信息",
|
||||
"analyze_health": "综合体检",
|
||||
"analyze_rhythm": "分析学习节律",
|
||||
"web_search": "网页搜索",
|
||||
"web_fetch": "网页抓取",
|
||||
"move": "移动任务",
|
||||
"place": "放置任务",
|
||||
"swap": "交换任务",
|
||||
"batch_move": "批量移动任务",
|
||||
"unplace": "移出任务安排",
|
||||
"upsert_task_class": "写入任务类",
|
||||
"context_tools_add": "激活工具域",
|
||||
"context_tools_remove": "移除工具域",
|
||||
}
|
||||
|
||||
if label, ok := displayNameMap[name]; ok {
|
||||
return label
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func formatToolArgValueByKeyCN(key string, value any) string {
|
||||
switch key {
|
||||
case "day_scope":
|
||||
scope := strings.ToLower(strings.TrimSpace(formatToolArgValueCN(value)))
|
||||
switch scope {
|
||||
case "workday":
|
||||
return "工作日"
|
||||
case "weekend":
|
||||
return "周末"
|
||||
case "all":
|
||||
return "全部日期"
|
||||
default:
|
||||
return scope
|
||||
}
|
||||
case "day_of_week":
|
||||
weekdays := parseAnyToIntSlice(value)
|
||||
if len(weekdays) <= 0 {
|
||||
return formatToolArgValueCN(value)
|
||||
}
|
||||
labels := make([]string, 0, len(weekdays))
|
||||
for _, day := range weekdays {
|
||||
labels = append(labels, fmt.Sprintf("周%d", day))
|
||||
if len(labels) >= 4 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(labels, "、")
|
||||
case "task_ids", "task_item_ids", "week_filter":
|
||||
values := parseAnyToIntSlice(value)
|
||||
if len(values) <= 0 {
|
||||
return formatToolArgValueCN(value)
|
||||
}
|
||||
items := make([]string, 0, len(values))
|
||||
for _, current := range values {
|
||||
items = append(items, strconv.Itoa(current))
|
||||
if len(items) >= 4 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(items, "、")
|
||||
case "url":
|
||||
return truncateToolSummaryCN(formatToolArgValueCN(value))
|
||||
case "reason", "title", "task_name", "query", "keyword":
|
||||
return truncateToolSummaryCN(formatToolArgValueCN(value))
|
||||
default:
|
||||
return formatToolArgValueCN(value)
|
||||
}
|
||||
}
|
||||
|
||||
func formatToolArgValueCN(value any) string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
text := strings.TrimSpace(v)
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
return text
|
||||
case int:
|
||||
return strconv.Itoa(v)
|
||||
case int8:
|
||||
return strconv.Itoa(int(v))
|
||||
case int16:
|
||||
return strconv.Itoa(int(v))
|
||||
case int32:
|
||||
return strconv.Itoa(int(v))
|
||||
case int64:
|
||||
return strconv.Itoa(int(v))
|
||||
case float32:
|
||||
return strings.TrimSpace(strconv.FormatFloat(float64(v), 'f', -1, 32))
|
||||
case float64:
|
||||
return strings.TrimSpace(strconv.FormatFloat(v, 'f', -1, 64))
|
||||
case bool:
|
||||
if v {
|
||||
return "是"
|
||||
}
|
||||
return "否"
|
||||
case []any:
|
||||
values := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
text := formatToolArgValueCN(item)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
values = append(values, text)
|
||||
if len(values) >= 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(values, "、")
|
||||
default:
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
if text == "" || text == "<nil>" || text == "map[]" {
|
||||
return ""
|
||||
}
|
||||
return text
|
||||
}
|
||||
}
|
||||
182
backend/services/agent/node/interrupt.go
Normal file
182
backend/services/agent/node/interrupt.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
)
|
||||
|
||||
const (
|
||||
interruptStageName = "interrupt"
|
||||
interruptSpeakBlockID = "interrupt.speak"
|
||||
interruptStatusBlockID = "interrupt.status"
|
||||
)
|
||||
|
||||
// InterruptNodeInput 描述中断节点单轮运行所需的最小依赖。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 不需要 LLM Client — 所有文本已在 PendingInteraction.DisplayText 里;
|
||||
// 2. RuntimeState 提供 PendingInteraction;
|
||||
// 3. ChunkEmitter 负责推送收尾消息。
|
||||
type InterruptNodeInput struct {
|
||||
RuntimeState *agentmodel.AgentRuntimeState
|
||||
ConversationContext *agentmodel.ConversationContext
|
||||
ChunkEmitter *agentstream.ChunkEmitter
|
||||
PersistVisibleMessage agentmodel.PersistVisibleMessageFunc
|
||||
}
|
||||
|
||||
// RunInterruptNode 执行一轮中断节点逻辑。
|
||||
//
|
||||
// 核心职责:
|
||||
// 1. ask_user → 把 DisplayText 当普通 assistant 消息伪流式输出,说完就停;
|
||||
// 2. confirm → 确认卡片已由 confirm 节点推送,无需额外输出;
|
||||
// 3. 状态持久化已由 agent_nodes 层统一处理,Interrupt 不再需要自行存快照;
|
||||
// 4. 节点结束后 graph 走 END,当前连接断开。
|
||||
//
|
||||
// 设计原则:
|
||||
// 1. 中断就是正常对话的结束 — 助手说了问题/确认卡片,然后停下来等用户回复;
|
||||
// 2. 用户下次回复时走正常 chat 入口,chat 节点负责 resume;
|
||||
// 3. 不做特殊 UI,不需要前端适配新的交互模式。
|
||||
func RunInterruptNode(ctx context.Context, input InterruptNodeInput) error {
|
||||
runtimeState, conversationContext, emitter, err := prepareInterruptNodeInput(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pending := runtimeState.PendingInteraction
|
||||
if pending == nil {
|
||||
// 无 pending interaction → 不应到达此处,防御性返回。
|
||||
return fmt.Errorf("interrupt node: 无待处理交互")
|
||||
}
|
||||
|
||||
switch pending.Type {
|
||||
case agentmodel.PendingInteractionTypeAskUser:
|
||||
return handleInterruptAskUser(ctx, runtimeState, input.PersistVisibleMessage, pending, conversationContext, emitter)
|
||||
case agentmodel.PendingInteractionTypeConfirm:
|
||||
return handleInterruptConfirm(pending, emitter)
|
||||
default:
|
||||
// connection_lost 等其他类型 → 仅持久化,不输出。
|
||||
return handleInterruptDefault(pending, emitter)
|
||||
}
|
||||
}
|
||||
|
||||
// handleInterruptAskUser 处理追问型中断。
|
||||
//
|
||||
// 把 PendingInteraction.DisplayText 当普通 assistant 消息伪流式输出,
|
||||
// 写入历史,然后结束。用户体验和正常对话一样 — 助手问了问题,停下来等回复。
|
||||
func handleInterruptAskUser(
|
||||
ctx context.Context,
|
||||
runtimeState *agentmodel.AgentRuntimeState,
|
||||
persist agentmodel.PersistVisibleMessageFunc,
|
||||
pending *agentmodel.PendingInteraction,
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
) error {
|
||||
text := pending.DisplayText
|
||||
if text == "" {
|
||||
text = "请补充更多信息。"
|
||||
}
|
||||
|
||||
speakStreamed := readPendingMetadataBool(pending, agentmodel.PendingMetaAskUserSpeakStreamed)
|
||||
historyAppended := readPendingMetadataBool(pending, agentmodel.PendingMetaAskUserHistoryAppended)
|
||||
|
||||
// 1. 若上游节点已流式推送过 ask_user 文本,则这里跳过二次正文推送;
|
||||
// 2. 这样既保留 interrupt 的统一收口状态,又避免前端出现重复气泡。
|
||||
if !speakStreamed {
|
||||
// 伪流式输出,和 chatReply 一样的体感。
|
||||
if err := emitter.EmitPseudoAssistantText(
|
||||
ctx, interruptSpeakBlockID, interruptStageName,
|
||||
text,
|
||||
agentstream.DefaultPseudoStreamOptions(),
|
||||
); err != nil {
|
||||
return fmt.Errorf("追问消息推送失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 写入对话历史,下一轮 resume 时 LLM 能看到这个上下文。
|
||||
msg := schema.AssistantMessage(text, nil)
|
||||
if !historyAppended {
|
||||
conversationContext.AppendHistory(msg)
|
||||
}
|
||||
persistVisibleAssistantMessage(ctx, persist, runtimeState.EnsureCommonState(), msg)
|
||||
|
||||
// 状态持久化已由 agent_nodes 层统一处理,此处不再需要自行存快照。
|
||||
|
||||
_ = emitter.EmitStatus(
|
||||
interruptStatusBlockID, interruptStageName,
|
||||
"ask_user", "已追问用户,等待回复。", false,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func readPendingMetadataBool(pending *agentmodel.PendingInteraction, key string) bool {
|
||||
if pending == nil || pending.Metadata == nil {
|
||||
return false
|
||||
}
|
||||
raw, exists := pending.Metadata[key]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
value, ok := raw.(bool)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// handleInterruptConfirm 处理确认型中断。
|
||||
//
|
||||
// 确认卡片已由 confirm 节点推送,这里只需推送状态通知并持久化。
|
||||
func handleInterruptConfirm(
|
||||
pending *agentmodel.PendingInteraction,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
) error {
|
||||
// 状态持久化已由 agent_nodes 层统一处理,此处不再需要自行存快照。
|
||||
|
||||
_ = emitter.EmitStatus(
|
||||
interruptStatusBlockID, interruptStageName,
|
||||
"confirm", "等待用户确认。", false,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleInterruptDefault 处理其他类型的中断(如 connection_lost)。
|
||||
func handleInterruptDefault(
|
||||
pending *agentmodel.PendingInteraction,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
) error {
|
||||
// 状态持久化已由 agent_nodes 层统一处理,此处不再需要自行存快照。
|
||||
|
||||
_ = emitter.EmitStatus(
|
||||
interruptStatusBlockID, interruptStageName,
|
||||
"interrupted", "会话已中断。", false,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// prepareInterruptNodeInput 校验并准备中断节点的运行态依赖。
|
||||
func prepareInterruptNodeInput(input InterruptNodeInput) (
|
||||
*agentmodel.AgentRuntimeState,
|
||||
*agentmodel.ConversationContext,
|
||||
*agentstream.ChunkEmitter,
|
||||
error,
|
||||
) {
|
||||
if input.RuntimeState == nil {
|
||||
return nil, nil, nil, fmt.Errorf("interrupt node: runtime state 不能为空")
|
||||
}
|
||||
input.RuntimeState.EnsureCommonState()
|
||||
if input.ConversationContext == nil {
|
||||
input.ConversationContext = agentmodel.NewConversationContext("")
|
||||
}
|
||||
if input.ChunkEmitter == nil {
|
||||
input.ChunkEmitter = agentstream.NewChunkEmitter(
|
||||
agentstream.NoopPayloadEmitter(), "", "", time.Now().Unix(),
|
||||
)
|
||||
}
|
||||
return input.RuntimeState, input.ConversationContext, input.ChunkEmitter, nil
|
||||
}
|
||||
121
backend/services/agent/node/llm_debug.go
Normal file
121
backend/services/agent/node/llm_debug.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// logNodeLLMContext 将某个节点即将送入 LLM 的完整消息上下文按统一格式打印到日志。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 统一输出 stage / phase / chat / round,方便按一次请求内的多次 LLM 调用串联排查;
|
||||
// 2. 完整展开 messages,不做截断,保证问题复现时能直接对照 prompt 组装结果;
|
||||
// 3. 该函数只负责调试日志,不参与任何业务判断,也不修改上下文内容。
|
||||
func logNodeLLMContext(
|
||||
stage string,
|
||||
phase string,
|
||||
flowState *agentmodel.CommonState,
|
||||
messages []*schema.Message,
|
||||
) {
|
||||
chatID := ""
|
||||
roundUsed := 0
|
||||
if flowState != nil {
|
||||
chatID = flowState.ConversationID
|
||||
roundUsed = flowState.RoundUsed
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"[DEBUG] %s LLM context begin phase=%s chat=%s round=%d message_count=%d\n%s\n[DEBUG] %s LLM context end phase=%s chat=%s round=%d",
|
||||
stage,
|
||||
strings.TrimSpace(phase),
|
||||
chatID,
|
||||
roundUsed,
|
||||
len(messages),
|
||||
formatLLMMessagesForDebug(messages),
|
||||
stage,
|
||||
strings.TrimSpace(phase),
|
||||
chatID,
|
||||
roundUsed,
|
||||
)
|
||||
}
|
||||
|
||||
// formatLLMMessagesForDebug 将本轮送入 LLM 的完整消息上下文展开成可读多行日志。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 按消息索引逐条输出,便于和上游上下文构造步骤逐项对齐;
|
||||
// 2. 完整输出 content / reasoning_content / tool_calls / extra,不做截断;
|
||||
// 3. 仅用于调试打点,不参与业务决策。
|
||||
func formatLLMMessagesForDebug(messages []*schema.Message) string {
|
||||
if len(messages) == 0 {
|
||||
return "(empty messages)"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for i, msg := range messages {
|
||||
sb.WriteString(fmt.Sprintf("----- message[%d] -----\n", i))
|
||||
if msg == nil {
|
||||
sb.WriteString("role: <nil>\n\n")
|
||||
continue
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("role: %s\n", msg.Role))
|
||||
|
||||
if strings.TrimSpace(msg.ToolCallID) != "" {
|
||||
sb.WriteString(fmt.Sprintf("tool_call_id: %s\n", msg.ToolCallID))
|
||||
}
|
||||
if strings.TrimSpace(msg.ToolName) != "" {
|
||||
sb.WriteString(fmt.Sprintf("tool_name: %s\n", msg.ToolName))
|
||||
}
|
||||
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
sb.WriteString("tool_calls:\n")
|
||||
for j, call := range msg.ToolCalls {
|
||||
sb.WriteString(fmt.Sprintf(" - [%d] id=%s type=%s function=%s\n", j, call.ID, call.Type, call.Function.Name))
|
||||
sb.WriteString(" arguments:\n")
|
||||
sb.WriteString(indentMultilineForDebug(call.Function.Arguments, " "))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(msg.ReasoningContent) != "" {
|
||||
sb.WriteString("reasoning_content:\n")
|
||||
sb.WriteString(indentMultilineForDebug(msg.ReasoningContent, " "))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("content:\n")
|
||||
sb.WriteString(indentMultilineForDebug(msg.Content, " "))
|
||||
sb.WriteString("\n")
|
||||
|
||||
if len(msg.Extra) > 0 {
|
||||
sb.WriteString("extra:\n")
|
||||
raw, err := json.MarshalIndent(msg.Extra, "", " ")
|
||||
if err != nil {
|
||||
sb.WriteString(indentMultilineForDebug("<marshal_error>", " "))
|
||||
} else {
|
||||
sb.WriteString(indentMultilineForDebug(string(raw), " "))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// indentMultilineForDebug 为多行文本统一添加前缀缩进,避免日志折行后难以阅读。
|
||||
func indentMultilineForDebug(text, prefix string) string {
|
||||
if text == "" {
|
||||
return prefix + "<empty>"
|
||||
}
|
||||
lines := strings.Split(text, "\n")
|
||||
for i := range lines {
|
||||
lines[i] = prefix + lines[i]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
398
backend/services/agent/node/plan.go
Normal file
398
backend/services/agent/node/plan.go
Normal file
@@ -0,0 +1,398 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentprompt "github.com/LoveLosita/smartflow/backend/services/agent/prompt"
|
||||
agentrouter "github.com/LoveLosita/smartflow/backend/services/agent/router"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
planStageName = "plan"
|
||||
planStatusBlockID = "plan.status"
|
||||
planSpeakBlockID = "plan.speak"
|
||||
planSummaryBlockID = "plan.summary"
|
||||
planPinnedKey = "current_plan"
|
||||
planCurrentStepKey = "current_step"
|
||||
planCurrentStepTitle = "当前步骤"
|
||||
planFullPlanTitle = "当前完整计划"
|
||||
)
|
||||
|
||||
// PlanNodeInput 描述单轮规划节点执行所需的最小依赖。
|
||||
type PlanNodeInput struct {
|
||||
RuntimeState *agentmodel.AgentRuntimeState
|
||||
ConversationContext *agentmodel.ConversationContext
|
||||
UserInput string
|
||||
Client *llmservice.Client
|
||||
ChunkEmitter *agentstream.ChunkEmitter
|
||||
ResumeNode string
|
||||
AlwaysExecute bool // true 时计划生成后自动确认,不进入 confirm 节点
|
||||
ThinkingEnabled bool // 是否开启 thinking,由 config.yaml 的 agent.thinking.plan 注入
|
||||
CompactionStore agentmodel.CompactionStore // 上下文压缩持久化
|
||||
PersistVisibleMessage agentmodel.PersistVisibleMessageFunc
|
||||
}
|
||||
|
||||
// RunPlanNode 执行一轮规划节点逻辑。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 先校验最小依赖,并推送一条"正在规划"的状态,避免用户空等;
|
||||
// 2. 构造本轮规划输入,调用 LLM Stream 接口;
|
||||
// 3. 从流中提取 <SMARTFLOW_DECISION> 标签内的 JSON 决策,同时流式推送 speak 正文;
|
||||
// 4. 按 action 推进流程:
|
||||
// 4.1 continue:继续停留在 planning;
|
||||
// 4.2 ask_user:打开 pending interaction,后续交给 interrupt 收口;
|
||||
// 4.3 plan_done:固化完整计划,刷新 pinned context,并进入 waiting_confirm。
|
||||
func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
|
||||
runtimeState, conversationContext, emitter, err := preparePlanNodeInput(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
flowState := runtimeState.EnsureCommonState()
|
||||
|
||||
// 1. 先发一条阶段状态,让前端知道当前已经进入规划环节。
|
||||
if err := emitter.EmitStatus(
|
||||
planStatusBlockID,
|
||||
planStageName,
|
||||
"planning",
|
||||
"正在梳理目标并补全执行计划。",
|
||||
false,
|
||||
); err != nil {
|
||||
return fmt.Errorf("规划阶段状态推送失败: %w", err)
|
||||
}
|
||||
|
||||
// 2. 构造本轮规划输入。
|
||||
messages := agentprompt.BuildPlanMessages(flowState, conversationContext, input.UserInput)
|
||||
messages = compactUnifiedMessagesIfNeeded(ctx, messages, UnifiedCompactInput{
|
||||
Client: input.Client,
|
||||
CompactionStore: input.CompactionStore,
|
||||
FlowState: flowState,
|
||||
Emitter: emitter,
|
||||
StageName: planStageName,
|
||||
StatusBlockID: planStatusBlockID,
|
||||
})
|
||||
logNodeLLMContext(planStageName, "planning", flowState, messages)
|
||||
|
||||
// 3. 两阶段流式规划:从 LLM 流中先提取 <SMARTFLOW_DECISION> 决策标签,再流式推送 speak 正文。
|
||||
reader, err := input.Client.Stream(
|
||||
ctx,
|
||||
messages,
|
||||
llmservice.GenerateOptions{
|
||||
Temperature: 0.2,
|
||||
// 显式设置上限,避免依赖框架默认值(默认 4096)导致长决策被截断。
|
||||
// 注意:当前模型接口 max_tokens 上限为 131072,超过会 400。
|
||||
MaxTokens: 131072,
|
||||
Thinking: resolveThinkingMode(input.ThinkingEnabled),
|
||||
Metadata: map[string]any{
|
||||
"stage": planStageName,
|
||||
"phase": "planning",
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("规划阶段 Stream 调用失败: %w", err)
|
||||
}
|
||||
|
||||
parser := agentrouter.NewStreamDecisionParser()
|
||||
firstChunk := true
|
||||
speakStreamed := false
|
||||
reasoningDigestor, digestorErr := emitter.NewReasoningDigestor(ctx, planSpeakBlockID, planStageName)
|
||||
if digestorErr != nil {
|
||||
return fmt.Errorf("规划 thinking 摘要器初始化失败: %w", digestorErr)
|
||||
}
|
||||
defer func() {
|
||||
if reasoningDigestor != nil {
|
||||
_ = reasoningDigestor.Close(ctx)
|
||||
}
|
||||
}()
|
||||
|
||||
// 3.1 阶段一:解析决策标签。
|
||||
for {
|
||||
chunk, recvErr := reader.Recv()
|
||||
if recvErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if recvErr != nil {
|
||||
log.Printf("[WARN] plan stream recv error chat=%s err=%v", flowState.ConversationID, recvErr)
|
||||
break
|
||||
}
|
||||
|
||||
// thinking 内容只进入摘要器,不再把 raw reasoning_content 透传给前端。
|
||||
if chunk != nil && strings.TrimSpace(chunk.ReasoningContent) != "" {
|
||||
if reasoningDigestor != nil {
|
||||
reasoningDigestor.Append(chunk.ReasoningContent)
|
||||
}
|
||||
}
|
||||
|
||||
content := ""
|
||||
if chunk != nil {
|
||||
content = chunk.Content
|
||||
}
|
||||
|
||||
visible, ready, _ := parser.Feed(content)
|
||||
if !ready {
|
||||
continue
|
||||
}
|
||||
|
||||
result := parser.Result()
|
||||
if result.Fallback || result.ParseFailed {
|
||||
return fmt.Errorf("规划解析失败,原始输出=%s", result.RawBuffer)
|
||||
}
|
||||
|
||||
decision, parseErr := llmservice.ParseJSONObject[agentmodel.PlanDecision](result.DecisionJSON)
|
||||
if parseErr != nil {
|
||||
return fmt.Errorf("规划决策 JSON 解析失败: %w (raw=%s)", parseErr, result.RawBuffer)
|
||||
}
|
||||
if validateErr := decision.Validate(); validateErr != nil {
|
||||
return fmt.Errorf("规划决策不合法: %w", validateErr)
|
||||
}
|
||||
|
||||
// 3.2 阶段二:流式推送 speak(同一 reader 继续读取)。
|
||||
var fullText strings.Builder
|
||||
if visible != "" {
|
||||
if reasoningDigestor != nil {
|
||||
reasoningDigestor.MarkContentStarted()
|
||||
}
|
||||
if emitErr := emitter.EmitAssistantText(planSpeakBlockID, planStageName, visible, firstChunk); emitErr != nil {
|
||||
return fmt.Errorf("规划文案推送失败: %w", emitErr)
|
||||
}
|
||||
speakStreamed = true
|
||||
fullText.WriteString(visible)
|
||||
firstChunk = false
|
||||
}
|
||||
for {
|
||||
chunk2, recvErr2 := reader.Recv()
|
||||
if recvErr2 == io.EOF {
|
||||
break
|
||||
}
|
||||
if recvErr2 != nil {
|
||||
log.Printf("[WARN] plan speak stream error chat=%s err=%v", flowState.ConversationID, recvErr2)
|
||||
break
|
||||
}
|
||||
if chunk2 == nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(chunk2.ReasoningContent) != "" {
|
||||
if reasoningDigestor != nil {
|
||||
reasoningDigestor.Append(chunk2.ReasoningContent)
|
||||
}
|
||||
}
|
||||
if chunk2.Content != "" {
|
||||
if reasoningDigestor != nil {
|
||||
reasoningDigestor.MarkContentStarted()
|
||||
}
|
||||
if emitErr := emitter.EmitAssistantText(planSpeakBlockID, planStageName, chunk2.Content, firstChunk); emitErr != nil {
|
||||
return fmt.Errorf("规划文案推送失败: %w", emitErr)
|
||||
}
|
||||
speakStreamed = true
|
||||
fullText.WriteString(chunk2.Content)
|
||||
firstChunk = false
|
||||
}
|
||||
}
|
||||
decision.Speak = fullText.String()
|
||||
|
||||
// 4. 若有 speak 且不是 ask_user(ask_user 交给 interrupt 收口),写入历史。
|
||||
if strings.TrimSpace(decision.Speak) != "" && decision.Action != agentmodel.PlanActionAskUser {
|
||||
msg := schema.AssistantMessage(decision.Speak, nil)
|
||||
conversationContext.AppendHistory(msg)
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
}
|
||||
|
||||
// 5. 按规划动作推进流程状态。
|
||||
return handlePlanAction(ctx, input, runtimeState, conversationContext, emitter, flowState, decision, speakStreamed)
|
||||
}
|
||||
|
||||
// 流结束但未找到决策标签。
|
||||
return fmt.Errorf("规划阶段流结束但未提取到决策标签")
|
||||
}
|
||||
|
||||
// handlePlanAction 根据 PlanDecision.Action 推进流程状态。
|
||||
func handlePlanAction(
|
||||
ctx context.Context,
|
||||
input PlanNodeInput,
|
||||
runtimeState *agentmodel.AgentRuntimeState,
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
emitter *agentstream.ChunkEmitter,
|
||||
flowState *agentmodel.CommonState,
|
||||
decision *agentmodel.PlanDecision,
|
||||
askUserSpeakStreamed bool,
|
||||
) error {
|
||||
switch decision.Action {
|
||||
case agentmodel.PlanActionContinue:
|
||||
flowState.Phase = agentmodel.PhasePlanning
|
||||
return nil
|
||||
case agentmodel.PlanActionAskUser:
|
||||
question := resolvePlanAskUserText(decision)
|
||||
runtimeState.OpenAskUserInteraction(uuid.NewString(), question, strings.TrimSpace(input.ResumeNode))
|
||||
// 1. plan 阶段若已流式推送过 ask_user 文本,interrupt 侧应避免重复正文输出;
|
||||
// 2. plan 阶段 ask_user 不会提前写入 history,这里显式标记为 false。
|
||||
runtimeState.SetPendingInteractionMetadata(agentmodel.PendingMetaAskUserSpeakStreamed, askUserSpeakStreamed)
|
||||
runtimeState.SetPendingInteractionMetadata(agentmodel.PendingMetaAskUserHistoryAppended, false)
|
||||
return nil
|
||||
case agentmodel.PlanActionDone:
|
||||
flowState.FinishPlan(decision.PlanSteps)
|
||||
flowState.PendingContextHook = clonePlanContextHook(decision.ContextHook)
|
||||
writePlanPinnedBlocks(conversationContext, decision.PlanSteps)
|
||||
if decision.NeedsRoughBuild {
|
||||
flowState.NeedsRoughBuild = true
|
||||
if len(decision.TaskClassIDs) > 0 {
|
||||
flowState.TaskClassIDs = decision.TaskClassIDs
|
||||
}
|
||||
}
|
||||
// always_execute 开启时,计划层跳过确认闸门,直接进入执行阶段。
|
||||
if input.AlwaysExecute {
|
||||
summary := strings.TrimSpace(buildPlanSummary(decision.PlanSteps))
|
||||
if summary != "" {
|
||||
msg := schema.AssistantMessage(summary, nil)
|
||||
if err := emitter.EmitPseudoAssistantText(
|
||||
ctx,
|
||||
planSummaryBlockID,
|
||||
planStageName,
|
||||
summary,
|
||||
agentstream.DefaultPseudoStreamOptions(),
|
||||
); err != nil {
|
||||
return fmt.Errorf("自动执行前计划摘要推送失败: %w", err)
|
||||
}
|
||||
conversationContext.AppendHistory(msg)
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
}
|
||||
|
||||
flowState.ConfirmPlan()
|
||||
_ = emitter.EmitStatus(
|
||||
planStatusBlockID,
|
||||
planStageName,
|
||||
"plan_auto_confirmed",
|
||||
"计划已自动确认,开始执行。",
|
||||
false,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
llmOutput := decision.Speak
|
||||
if strings.TrimSpace(llmOutput) == "" {
|
||||
llmOutput = decision.Reason
|
||||
}
|
||||
AppendLLMCorrectionWithHint(
|
||||
conversationContext,
|
||||
llmOutput,
|
||||
fmt.Sprintf("你输出的 action \"%s\" 不是合法的执行动作。", decision.Action),
|
||||
"合法的 action 包括:continue(继续当前步骤)、ask_user(追问用户)、next_plan(推进到下一步)、done(任务完成)。",
|
||||
)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func preparePlanNodeInput(input PlanNodeInput) (*agentmodel.AgentRuntimeState, *agentmodel.ConversationContext, *agentstream.ChunkEmitter, error) {
|
||||
if input.RuntimeState == nil {
|
||||
return nil, nil, nil, fmt.Errorf("plan node: runtime state 不能为空")
|
||||
}
|
||||
if input.Client == nil {
|
||||
return nil, nil, nil, fmt.Errorf("plan node: plan client 未注入")
|
||||
}
|
||||
|
||||
input.RuntimeState.EnsureCommonState()
|
||||
if input.ConversationContext == nil {
|
||||
input.ConversationContext = agentmodel.NewConversationContext("")
|
||||
}
|
||||
if input.ChunkEmitter == nil {
|
||||
input.ChunkEmitter = agentstream.NewChunkEmitter(agentstream.NoopPayloadEmitter(), "", "", time.Now().Unix())
|
||||
}
|
||||
return input.RuntimeState, input.ConversationContext, input.ChunkEmitter, nil
|
||||
}
|
||||
|
||||
func resolvePlanAskUserText(decision *agentmodel.PlanDecision) string {
|
||||
if decision == nil {
|
||||
return "我还缺一点关键信息,想先向你确认一下。"
|
||||
}
|
||||
if strings.TrimSpace(decision.Speak) != "" {
|
||||
return strings.TrimSpace(decision.Speak)
|
||||
}
|
||||
if strings.TrimSpace(decision.Reason) != "" {
|
||||
return strings.TrimSpace(decision.Reason)
|
||||
}
|
||||
return "我还缺一点关键信息,想先向你确认一下。"
|
||||
}
|
||||
|
||||
func clonePlanContextHook(hook *agentmodel.ContextHook) *agentmodel.ContextHook {
|
||||
if hook == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *hook
|
||||
if len(hook.Packs) > 0 {
|
||||
cloned.Packs = append([]string(nil), hook.Packs...)
|
||||
}
|
||||
cloned.Normalize()
|
||||
if cloned.Domain == "" {
|
||||
return nil
|
||||
}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func writePlanPinnedBlocks(ctx *agentmodel.ConversationContext, steps []agentmodel.PlanStep) {
|
||||
if ctx == nil {
|
||||
return
|
||||
}
|
||||
|
||||
fullPlanText := buildPinnedPlanText(steps)
|
||||
if strings.TrimSpace(fullPlanText) != "" {
|
||||
ctx.UpsertPinnedBlock(agentmodel.ContextBlock{
|
||||
Key: planPinnedKey,
|
||||
Title: planFullPlanTitle,
|
||||
Content: fullPlanText,
|
||||
})
|
||||
}
|
||||
|
||||
if len(steps) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
firstStep := strings.TrimSpace(steps[0].Content)
|
||||
if strings.TrimSpace(steps[0].DoneWhen) != "" {
|
||||
firstStep = fmt.Sprintf("%s\n完成判定:%s", firstStep, strings.TrimSpace(steps[0].DoneWhen))
|
||||
}
|
||||
ctx.UpsertPinnedBlock(agentmodel.ContextBlock{
|
||||
Key: planCurrentStepKey,
|
||||
Title: planCurrentStepTitle,
|
||||
Content: firstStep,
|
||||
})
|
||||
}
|
||||
|
||||
func buildPinnedPlanText(steps []agentmodel.PlanStep) string {
|
||||
if len(steps) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(steps))
|
||||
for i, step := range steps {
|
||||
content := strings.TrimSpace(step.Content)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("%d. %s", i+1, content)
|
||||
if strings.TrimSpace(step.DoneWhen) != "" {
|
||||
line += fmt.Sprintf("\n完成判定:%s", strings.TrimSpace(step.DoneWhen))
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(lines, "\n\n"))
|
||||
}
|
||||
|
||||
// resolveThinkingMode 根据配置布尔值返回对应的 ThinkingMode。
|
||||
// 供 plan / execute / deliver 节点统一使用。
|
||||
func resolveThinkingMode(enabled bool) llmservice.ThinkingMode {
|
||||
if enabled {
|
||||
return llmservice.ThinkingModeEnabled
|
||||
}
|
||||
return llmservice.ThinkingModeDisabled
|
||||
}
|
||||
584
backend/services/agent/node/quick_task.go
Normal file
584
backend/services/agent/node/quick_task.go
Normal file
@@ -0,0 +1,584 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
taskmodel "github.com/LoveLosita/smartflow/backend/model"
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentprompt "github.com/LoveLosita/smartflow/backend/services/agent/prompt"
|
||||
agentrouter "github.com/LoveLosita/smartflow/backend/services/agent/router"
|
||||
agentshared "github.com/LoveLosita/smartflow/backend/services/agent/shared"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
quickTaskStageName = "quick_task"
|
||||
quickTaskBlockID = "qt_main"
|
||||
quickTaskResultCardID = "quick_task.result"
|
||||
taskRecordSourceQuickNote = "quick_note"
|
||||
)
|
||||
|
||||
// QuickTaskNodeInput 描述快捷任务节点的输入。
|
||||
type QuickTaskNodeInput struct {
|
||||
RuntimeState *agentmodel.AgentRuntimeState
|
||||
ConversationContext *agentmodel.ConversationContext
|
||||
UserInput string
|
||||
Client *llmservice.Client
|
||||
ChunkEmitter *agentstream.ChunkEmitter
|
||||
QuickTaskDeps agentmodel.QuickTaskDeps
|
||||
PersistVisibleMessage agentmodel.PersistVisibleMessageFunc
|
||||
}
|
||||
|
||||
// quickTaskDecision 是从 LLM 输出中解析的结构化意图。
|
||||
type quickTaskDecision struct {
|
||||
Action string `json:"action"`
|
||||
Title string `json:"title,omitempty"`
|
||||
DeadlineAt string `json:"deadline_at,omitempty"`
|
||||
PriorityGroup *int `json:"priority_group,omitempty"`
|
||||
EstimatedSections *int `json:"estimated_sections,omitempty"`
|
||||
UrgencyThresholdAt string `json:"urgency_threshold_at,omitempty"`
|
||||
TaskID *int `json:"task_id,omitempty"`
|
||||
|
||||
// query 参数
|
||||
Quadrant *int `json:"quadrant,omitempty"`
|
||||
Keyword string `json:"keyword,omitempty"`
|
||||
Limit *int `json:"limit,omitempty"`
|
||||
DeadlineAfter string `json:"deadline_after,omitempty"`
|
||||
DeadlineBefore string `json:"deadline_before,omitempty"`
|
||||
|
||||
// ask 参数
|
||||
Question string `json:"question,omitempty"`
|
||||
}
|
||||
|
||||
// quickTaskActionResult 是 quick_task 执行动作后的统一回包。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. AssistantText 是本轮要补发给用户的短正文;
|
||||
// 2. BusinessCard 仅在“业务真实成功”时携带,失败/追问场景必须为空;
|
||||
// 3. 不负责直接发射,发射时机由 RunQuickTaskNode 统一控制。
|
||||
type quickTaskActionResult struct {
|
||||
AssistantText string
|
||||
BusinessCard *agentstream.StreamBusinessCardExtra
|
||||
}
|
||||
|
||||
// RunQuickTaskNode 执行快捷任务节点:流式 LLM 提取意图 → 直接调 service → 追加结果。
|
||||
func RunQuickTaskNode(ctx context.Context, input QuickTaskNodeInput) error {
|
||||
flowState := input.RuntimeState.EnsureCommonState()
|
||||
emitter := input.ChunkEmitter
|
||||
|
||||
// 1. 构造 messages。
|
||||
messages := agentprompt.BuildQuickTaskMessagesSimple(input.UserInput)
|
||||
|
||||
// 2. 真流式调用 LLM。
|
||||
reader, err := input.Client.Stream(ctx, messages, llmservice.GenerateOptions{
|
||||
Temperature: 0.3,
|
||||
MaxTokens: 512,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[WARN] quick_task: Stream 调用失败 chat=%s err=%v", flowState.ConversationID, err)
|
||||
_ = emitter.EmitAssistantText(quickTaskBlockID, quickTaskStageName, "抱歉,处理任务时出了点问题,请重试。", true)
|
||||
flowState.Phase = agentmodel.PhaseDone
|
||||
return nil
|
||||
}
|
||||
|
||||
// 3. 两阶段流式解析。
|
||||
parser := agentrouter.NewStreamDecisionParser()
|
||||
firstChunk := true
|
||||
var decision *quickTaskDecision
|
||||
var fullText strings.Builder
|
||||
|
||||
// 阶段一:解析决策标签。
|
||||
for {
|
||||
chunk, recvErr := reader.Recv()
|
||||
if recvErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if recvErr != nil {
|
||||
log.Printf("[WARN] quick_task stream recv error chat=%s err=%v", flowState.ConversationID, recvErr)
|
||||
break
|
||||
}
|
||||
|
||||
content := ""
|
||||
if chunk != nil {
|
||||
content = chunk.Content
|
||||
}
|
||||
|
||||
visible, ready, _ := parser.Feed(content)
|
||||
if !ready {
|
||||
continue
|
||||
}
|
||||
|
||||
result := parser.Result()
|
||||
|
||||
// Fallback / 解析失败:把原始文本当作纯回复推送。
|
||||
if result.Fallback || result.ParseFailed {
|
||||
log.Printf("[DEBUG] quick_task: 标签解析失败 chat=%s raw=%s", flowState.ConversationID, result.RawBuffer)
|
||||
if result.RawBuffer != "" {
|
||||
_ = emitter.EmitAssistantText(quickTaskBlockID, quickTaskStageName, result.RawBuffer, firstChunk)
|
||||
fullText.WriteString(result.RawBuffer)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// 解析 JSON。
|
||||
log.Printf("[DEBUG] quick_task: LLM 原始决策 JSON chat=%s json=%s", flowState.ConversationID, result.DecisionJSON)
|
||||
var parseErr error
|
||||
decision, parseErr = llmservice.ParseJSONObject[quickTaskDecision](result.DecisionJSON)
|
||||
if parseErr != nil {
|
||||
log.Printf("[DEBUG] quick_task: JSON 解析失败 chat=%s json=%s", flowState.ConversationID, result.DecisionJSON)
|
||||
if result.RawBuffer != "" {
|
||||
_ = emitter.EmitAssistantText(quickTaskBlockID, quickTaskStageName, result.RawBuffer, firstChunk)
|
||||
fullText.WriteString(result.RawBuffer)
|
||||
}
|
||||
break
|
||||
}
|
||||
log.Printf("[DEBUG] quick_task: 解析结果 chat=%s action=%s title=%s deadline_at=%s priority_group=%v estimated_sections=%v urgency_threshold_at=%q",
|
||||
flowState.ConversationID, decision.Action, decision.Title, decision.DeadlineAt, decision.PriorityGroup, decision.EstimatedSections, decision.UrgencyThresholdAt)
|
||||
|
||||
// 阶段二:流式推送标签后正文。
|
||||
if visible != "" {
|
||||
if emitErr := emitter.EmitAssistantText(quickTaskBlockID, quickTaskStageName, visible, firstChunk); emitErr != nil {
|
||||
log.Printf("[WARN] quick_task emit error chat=%s err=%v", flowState.ConversationID, emitErr)
|
||||
}
|
||||
fullText.WriteString(visible)
|
||||
firstChunk = false
|
||||
}
|
||||
for {
|
||||
chunk2, recvErr2 := reader.Recv()
|
||||
if recvErr2 == io.EOF {
|
||||
break
|
||||
}
|
||||
if recvErr2 != nil {
|
||||
log.Printf("[WARN] quick_task stream error chat=%s err=%v", flowState.ConversationID, recvErr2)
|
||||
break
|
||||
}
|
||||
if chunk2 == nil || chunk2.Content == "" {
|
||||
continue
|
||||
}
|
||||
if emitErr := emitter.EmitAssistantText(quickTaskBlockID, quickTaskStageName, chunk2.Content, firstChunk); emitErr != nil {
|
||||
log.Printf("[WARN] quick_task emit error chat=%s err=%v", flowState.ConversationID, emitErr)
|
||||
}
|
||||
fullText.WriteString(chunk2.Content)
|
||||
firstChunk = false
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// 4. 流结束但未解析到决策 → 降级为纯文本回复。
|
||||
if decision == nil {
|
||||
finalText := fullText.String()
|
||||
if strings.TrimSpace(finalText) == "" {
|
||||
finalText = "抱歉,处理任务时出了点问题,请重试。"
|
||||
_ = emitter.EmitAssistantText(quickTaskBlockID, quickTaskStageName, finalText, true)
|
||||
}
|
||||
msg := schema.AssistantMessage(finalText, nil)
|
||||
input.ConversationContext.AppendHistory(msg)
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
flowState.Phase = agentmodel.PhaseDone
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("[DEBUG] quick_task: chat=%s action=%s raw_title=%s", flowState.ConversationID, decision.Action, decision.Title)
|
||||
|
||||
// 5. 根据意图执行操作。
|
||||
result := quickTaskActionResult{}
|
||||
switch decision.Action {
|
||||
case "create":
|
||||
result = handleQuickTaskCreate(ctx, input, decision, flowState)
|
||||
case "query":
|
||||
result = handleQuickTaskQuery(ctx, input, decision, flowState)
|
||||
case "ask":
|
||||
result.AssistantText = decision.Question
|
||||
if result.AssistantText == "" {
|
||||
result.AssistantText = "你想记录什么呢?告诉我具体内容吧。"
|
||||
}
|
||||
default:
|
||||
result.AssistantText = "抱歉,我没有理解你的意思。你可以试试说「记一下明天开会」或「看看我的任务」。"
|
||||
}
|
||||
|
||||
// 6. 追加操作结果正文。
|
||||
if result.AssistantText != "" {
|
||||
_ = emitter.EmitAssistantText(quickTaskBlockID, quickTaskStageName, result.AssistantText, false)
|
||||
fullText.WriteString(result.AssistantText)
|
||||
}
|
||||
|
||||
messagePersisted := false
|
||||
// 7.1 有业务卡片时,先落正文,再发卡片,保证 timeline 顺序与前端展示一致。
|
||||
// 1. 先持久化正文,确保 timeline 里的 assistant_text seq 一定早于 business_card;
|
||||
// 2. 再发 business_card,保证“短正文 + 紧跟卡片”的时序契约;
|
||||
// 3. 卡片发射失败只记日志,不回滚正文,避免用户侧出现“看不到结果文本”的回退。
|
||||
if result.BusinessCard != nil {
|
||||
finalText := fullText.String()
|
||||
if strings.TrimSpace(finalText) != "" {
|
||||
msg := schema.AssistantMessage(finalText, nil)
|
||||
input.ConversationContext.AppendHistory(msg)
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
messagePersisted = true
|
||||
}
|
||||
if emitErr := emitter.EmitBusinessCard(quickTaskResultCardID, quickTaskStageName, result.BusinessCard); emitErr != nil {
|
||||
log.Printf("[WARN] quick_task emit business_card error chat=%s err=%v", flowState.ConversationID, emitErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 7.2 非卡片路径沿用原有收口:本轮正文统一一次性写入 history。
|
||||
if !messagePersisted {
|
||||
finalText := fullText.String()
|
||||
msg := schema.AssistantMessage(finalText, nil)
|
||||
input.ConversationContext.AppendHistory(msg)
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
}
|
||||
|
||||
flowState.Phase = agentmodel.PhaseDone
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleQuickTaskCreate 处理任务创建。
|
||||
func handleQuickTaskCreate(
|
||||
ctx context.Context,
|
||||
input QuickTaskNodeInput,
|
||||
decision *quickTaskDecision,
|
||||
flowState *agentmodel.CommonState,
|
||||
) quickTaskActionResult {
|
||||
_ = ctx
|
||||
title := strings.TrimSpace(decision.Title)
|
||||
if title == "" {
|
||||
return quickTaskActionResult{AssistantText: "你想记录什么呢?告诉我具体内容吧。"}
|
||||
}
|
||||
|
||||
var deadline *time.Time
|
||||
if raw := strings.TrimSpace(decision.DeadlineAt); raw != "" {
|
||||
parsed, err := agentshared.ParseOptionalDeadline(raw)
|
||||
if err != nil {
|
||||
return quickTaskActionResult{AssistantText: fmt.Sprintf("截止时间格式不太对(%s),不过我先把任务记下来啦。", err)}
|
||||
}
|
||||
deadline = parsed
|
||||
}
|
||||
|
||||
priorityGroup := 0
|
||||
if decision.PriorityGroup != nil && agentshared.IsValidTaskPriority(*decision.PriorityGroup) {
|
||||
priorityGroup = *decision.PriorityGroup
|
||||
}
|
||||
if priorityGroup == 0 {
|
||||
priorityGroup = quickNoteFallbackPriority(deadline)
|
||||
}
|
||||
estimatedSections := taskmodel.NormalizeEstimatedSections(decision.EstimatedSections)
|
||||
|
||||
var urgencyThreshold *time.Time
|
||||
if raw := strings.TrimSpace(decision.UrgencyThresholdAt); raw != "" {
|
||||
parsed, err := agentshared.ParseOptionalDeadline(raw)
|
||||
if err == nil {
|
||||
urgencyThreshold = parsed
|
||||
}
|
||||
}
|
||||
// LLM 经常省略 urgency_threshold_at,代码兜底:priorityGroup=2 且有 deadline 时自动推算。
|
||||
if urgencyThreshold == nil && priorityGroup == 2 && deadline != nil {
|
||||
fallback := deadline.Add(-24 * time.Hour)
|
||||
urgencyThreshold = &fallback
|
||||
}
|
||||
|
||||
log.Printf("[DEBUG] quick_task: CreateTask 参数 chat=%s title=%s priorityGroup=%d estimatedSections=%d deadline=%v urgencyThreshold=%v urgency_raw=%q estimated_raw=%v",
|
||||
flowState.ConversationID, title, priorityGroup, estimatedSections, deadline, urgencyThreshold, decision.UrgencyThresholdAt, decision.EstimatedSections)
|
||||
taskID, err := input.QuickTaskDeps.CreateTask(flowState.UserID, title, priorityGroup, estimatedSections, deadline, urgencyThreshold)
|
||||
if err != nil {
|
||||
return quickTaskActionResult{AssistantText: fmt.Sprintf("记录失败了(%s),稍后再试试?", err)}
|
||||
}
|
||||
|
||||
flowState.UsedQuickNote = true
|
||||
return quickTaskActionResult{
|
||||
AssistantText: "已帮你记下这条任务。",
|
||||
BusinessCard: buildTaskRecordBusinessCard(taskID, title, priorityGroup, estimatedSections, deadline, urgencyThreshold),
|
||||
}
|
||||
}
|
||||
|
||||
// handleQuickTaskQuery 处理任务查询。
|
||||
func handleQuickTaskQuery(
|
||||
ctx context.Context,
|
||||
input QuickTaskNodeInput,
|
||||
decision *quickTaskDecision,
|
||||
flowState *agentmodel.CommonState,
|
||||
) quickTaskActionResult {
|
||||
params := agentmodel.TaskQueryParams{
|
||||
SortBy: "deadline",
|
||||
Order: "asc",
|
||||
Limit: 5,
|
||||
IncludeCompleted: false,
|
||||
}
|
||||
|
||||
if decision.Quadrant != nil && *decision.Quadrant >= 1 && *decision.Quadrant <= 4 {
|
||||
params.Quadrant = decision.Quadrant
|
||||
}
|
||||
if kw := strings.TrimSpace(decision.Keyword); kw != "" {
|
||||
params.Keyword = kw
|
||||
}
|
||||
if decision.Limit != nil && *decision.Limit > 0 && *decision.Limit <= 20 {
|
||||
params.Limit = *decision.Limit
|
||||
}
|
||||
params.DeadlineAfter = parseQuickTaskQueryDeadlineBoundary(decision.DeadlineAfter, "deadline_after", flowState)
|
||||
params.DeadlineBefore = parseQuickTaskQueryDeadlineBoundary(decision.DeadlineBefore, "deadline_before", flowState)
|
||||
// 1. 若模型给出了颠倒的时间窗(before<=after),当前轮降级为“不加时间窗”继续查询;
|
||||
// 2. 这样能避免误筛选成空结果,同时把异常留给日志排查;
|
||||
// 3. 这里只做兜底,不尝试替模型自动纠正语义,避免引入额外猜测。
|
||||
if params.DeadlineAfter != nil && params.DeadlineBefore != nil && !params.DeadlineBefore.After(*params.DeadlineAfter) {
|
||||
log.Printf("[WARN] quick_task: query 时间窗无效 chat=%s after=%s before=%s,已降级为无时间窗筛选",
|
||||
flowState.ConversationID,
|
||||
formatQuickTaskTime(params.DeadlineAfter),
|
||||
formatQuickTaskTime(params.DeadlineBefore),
|
||||
)
|
||||
params.DeadlineAfter = nil
|
||||
params.DeadlineBefore = nil
|
||||
}
|
||||
|
||||
results, err := input.QuickTaskDeps.QueryTasks(ctx, flowState.UserID, params)
|
||||
if err != nil {
|
||||
return quickTaskActionResult{AssistantText: fmt.Sprintf("查询失败了(%s),稍后再试试?", err)}
|
||||
}
|
||||
|
||||
card := buildTaskQueryBusinessCard(params, results)
|
||||
if len(results) == 0 {
|
||||
return quickTaskActionResult{
|
||||
AssistantText: "我这边没查到匹配任务。",
|
||||
BusinessCard: card,
|
||||
}
|
||||
}
|
||||
|
||||
return quickTaskActionResult{
|
||||
AssistantText: fmt.Sprintf("我找到 %d 条任务,整理成卡片给你。", len(results)),
|
||||
BusinessCard: card,
|
||||
}
|
||||
}
|
||||
|
||||
func buildTaskRecordBusinessCard(taskID int, title string, priorityGroup int, estimatedSections int, deadline *time.Time, urgencyThreshold *time.Time) *agentstream.StreamBusinessCardExtra {
|
||||
data := map[string]any{
|
||||
"id": taskID,
|
||||
"title": strings.TrimSpace(title),
|
||||
"priority_group": priorityGroup,
|
||||
"estimated_sections": estimatedSections,
|
||||
"priority_label": agentshared.PriorityLabelCN(priorityGroup),
|
||||
"status": "todo",
|
||||
}
|
||||
if formatted := formatQuickTaskTime(deadline); formatted != "" {
|
||||
data["deadline_at"] = formatted
|
||||
}
|
||||
if formatted := formatQuickTaskTime(urgencyThreshold); formatted != "" {
|
||||
data["urgency_threshold_at"] = formatted
|
||||
}
|
||||
|
||||
// 说明:
|
||||
// 1. quick_task 当前只有 action=create,未显式区分“随口记 / 正式创建任务”;
|
||||
// 2. 仅凭当前 prompt 决策无法稳定判断 source=create_task,会引入误判;
|
||||
// 3. 本轮按最小安全口径固定为 quick_note,等后续补稳定判别字段再切分。
|
||||
return &agentstream.StreamBusinessCardExtra{
|
||||
CardType: "task_record",
|
||||
Title: "已帮你记下",
|
||||
Summary: "一条轻量提醒已写入任务系统",
|
||||
Source: taskRecordSourceQuickNote,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func buildTaskQueryBusinessCard(params agentmodel.TaskQueryParams, results []agentmodel.TaskQueryResult) *agentstream.StreamBusinessCardExtra {
|
||||
taskItems := make([]map[string]any, 0, len(results))
|
||||
for _, task := range results {
|
||||
item := map[string]any{
|
||||
"id": task.ID,
|
||||
"title": strings.TrimSpace(task.Title),
|
||||
"priority_group": task.PriorityGroup,
|
||||
"estimated_sections": task.EstimatedSections,
|
||||
"priority_label": agentshared.PriorityLabelCN(task.PriorityGroup),
|
||||
"is_completed": task.IsCompleted,
|
||||
}
|
||||
if deadline := strings.TrimSpace(task.DeadlineAt); deadline != "" {
|
||||
item["deadline_at"] = deadline
|
||||
}
|
||||
taskItems = append(taskItems, item)
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("找到 %d 条任务", len(results))
|
||||
if len(results) == 0 {
|
||||
title = "未找到匹配任务"
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"result_count": len(results),
|
||||
"shown_count": len(results),
|
||||
"tasks": taskItems,
|
||||
}
|
||||
queryFilters := buildTaskQueryFilters(params)
|
||||
if len(queryFilters) > 0 {
|
||||
data["query_filters"] = queryFilters
|
||||
}
|
||||
querySummary := buildTaskQuerySummary(queryFilters)
|
||||
if querySummary != "" {
|
||||
data["query_summary"] = querySummary
|
||||
}
|
||||
|
||||
return &agentstream.StreamBusinessCardExtra{
|
||||
CardType: "task_query",
|
||||
Title: title,
|
||||
Summary: querySummary,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
// buildTaskQueryFilter 生成查询条件的稳定结构化描述。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. key/operator/value 提供前端可依赖的机器语义;
|
||||
// 2. label/display_text 提供前端可直接展示的中文文案;
|
||||
// 3. query_summary 只能从 display_text 派生,前端不要再反向解析 summary。
|
||||
func buildTaskQueryFilter(key string, label string, value any, operator string, displayText string) map[string]any {
|
||||
filter := map[string]any{
|
||||
"key": key,
|
||||
"label": label,
|
||||
"value": value,
|
||||
"display_text": strings.TrimSpace(displayText),
|
||||
}
|
||||
if strings.TrimSpace(operator) != "" {
|
||||
filter["operator"] = strings.TrimSpace(operator)
|
||||
}
|
||||
return filter
|
||||
}
|
||||
|
||||
func buildTaskQueryFilters(params agentmodel.TaskQueryParams) []map[string]any {
|
||||
filters := make([]map[string]any, 0, 6)
|
||||
if params.Quadrant != nil && *params.Quadrant >= 1 && *params.Quadrant <= 4 {
|
||||
label := agentshared.PriorityLabelCN(*params.Quadrant)
|
||||
filters = append(filters, buildTaskQueryFilter(
|
||||
"quadrant",
|
||||
"象限",
|
||||
*params.Quadrant,
|
||||
"eq",
|
||||
fmt.Sprintf("象限:%s", label),
|
||||
))
|
||||
}
|
||||
if kw := strings.TrimSpace(params.Keyword); kw != "" {
|
||||
filters = append(filters, buildTaskQueryFilter(
|
||||
"keyword",
|
||||
"关键词",
|
||||
kw,
|
||||
"contains",
|
||||
fmt.Sprintf("关键词:%s", kw),
|
||||
))
|
||||
}
|
||||
if params.DeadlineAfter != nil {
|
||||
formatted := formatQuickTaskTime(params.DeadlineAfter)
|
||||
filters = append(filters, buildTaskQueryFilter(
|
||||
"deadline_after",
|
||||
"截止起始",
|
||||
formatted,
|
||||
"gte",
|
||||
fmt.Sprintf("截止时间≥%s", formatted),
|
||||
))
|
||||
}
|
||||
if params.DeadlineBefore != nil {
|
||||
formatted := formatQuickTaskTime(params.DeadlineBefore)
|
||||
filters = append(filters, buildTaskQueryFilter(
|
||||
"deadline_before",
|
||||
"截止结束",
|
||||
formatted,
|
||||
"lt",
|
||||
fmt.Sprintf("截止时间<%s", formatted),
|
||||
))
|
||||
}
|
||||
if !params.IncludeCompleted {
|
||||
filters = append(filters, buildTaskQueryFilter(
|
||||
"include_completed",
|
||||
"完成状态",
|
||||
false,
|
||||
"eq",
|
||||
"仅未完成",
|
||||
))
|
||||
}
|
||||
|
||||
sortValue := "deadline_asc"
|
||||
sortDisplay := "按截止时间升序"
|
||||
switch strings.ToLower(strings.TrimSpace(params.SortBy)) {
|
||||
case "priority":
|
||||
if strings.ToLower(strings.TrimSpace(params.Order)) == "desc" {
|
||||
sortValue = "priority_desc"
|
||||
sortDisplay = "按优先级降序"
|
||||
} else {
|
||||
sortValue = "priority_asc"
|
||||
sortDisplay = "按优先级升序"
|
||||
}
|
||||
case "id":
|
||||
if strings.ToLower(strings.TrimSpace(params.Order)) == "desc" {
|
||||
sortValue = "id_desc"
|
||||
sortDisplay = "按创建顺序倒序"
|
||||
} else {
|
||||
sortValue = "id_asc"
|
||||
sortDisplay = "按创建顺序正序"
|
||||
}
|
||||
default:
|
||||
if strings.ToLower(strings.TrimSpace(params.Order)) == "desc" {
|
||||
sortValue = "deadline_desc"
|
||||
sortDisplay = "按截止时间降序"
|
||||
}
|
||||
}
|
||||
filters = append(filters, buildTaskQueryFilter(
|
||||
"sort",
|
||||
"排序",
|
||||
sortValue,
|
||||
"eq",
|
||||
sortDisplay,
|
||||
))
|
||||
return filters
|
||||
}
|
||||
|
||||
func buildTaskQuerySummary(filters []map[string]any) string {
|
||||
parts := make([]string, 0, len(filters))
|
||||
for _, filter := range filters {
|
||||
if text, ok := filter["display_text"].(string); ok && strings.TrimSpace(text) != "" {
|
||||
parts = append(parts, strings.TrimSpace(text))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
|
||||
// parseQuickTaskQueryDeadlineBoundary 解析 quick_task 查询时间窗边界。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责把 query 的 deadline_after/deadline_before 文本解析成时间;
|
||||
// 2. 解析失败时仅记录日志并返回 nil,不中断查询主链路;
|
||||
// 3. 不负责时间窗合法性校验(如 before<=after),该校验由调用方统一处理。
|
||||
func parseQuickTaskQueryDeadlineBoundary(raw string, field string, flowState *agentmodel.CommonState) *time.Time {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
parsed, err := agentshared.ParseOptionalDeadline(value)
|
||||
if err != nil {
|
||||
chatID := ""
|
||||
if flowState != nil {
|
||||
chatID = flowState.ConversationID
|
||||
}
|
||||
log.Printf("[WARN] quick_task: query %s 解析失败 chat=%s raw=%q err=%v,已降级为无该筛选条件", field, chatID, value, err)
|
||||
return nil
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func formatQuickTaskTime(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.In(agentshared.ShanghaiLocation()).Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
// quickNoteFallbackPriority 根据截止时间推断默认优先级。
|
||||
func quickNoteFallbackPriority(deadline *time.Time) int {
|
||||
if deadline != nil {
|
||||
if time.Until(*deadline) <= 48*time.Hour {
|
||||
return agentshared.QuickNotePriorityImportantUrgent
|
||||
}
|
||||
return agentshared.QuickNotePriorityImportantNotUrgent
|
||||
}
|
||||
return agentshared.QuickNotePrioritySimpleNotImportant
|
||||
}
|
||||
394
backend/services/agent/node/rough_build.go
Normal file
394
backend/services/agent/node/rough_build.go
Normal file
@@ -0,0 +1,394 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agenttools "github.com/LoveLosita/smartflow/backend/services/agent/tools"
|
||||
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
)
|
||||
|
||||
const (
|
||||
roughBuildStageName = "rough_build"
|
||||
roughBuildStatusBlock = "rough_build.status"
|
||||
roughBuildSampleLimit = 3
|
||||
)
|
||||
|
||||
type roughBuildApplyStats struct {
|
||||
AppliedCount int
|
||||
DayMappingMissCount int
|
||||
TaskItemMatchMissCount int
|
||||
DayMappingMissSamples []string
|
||||
TaskItemMatchMissSamples []string
|
||||
}
|
||||
|
||||
// RunRoughBuildNode 执行粗排节点逻辑。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 推送"正在粗排"状态给前端;
|
||||
// 2. 从 CommonState 读取 TaskClassIDs,确认有需要排课的任务类;
|
||||
// 3. 加载 ScheduleState(含 DayMapping);
|
||||
// 4. 调用 RoughBuildFunc 拿到粗排结果([]RoughBuildPlacement);
|
||||
// 5. 把粗排结果写入 ScheduleState,把已落位任务标记为 suggested;
|
||||
// 6. 若粗排后仍存在真实 pending,则写入正式 abort 结果并结束本轮;
|
||||
// 7. 否则按“是否需要粗排后立即微调”分流:
|
||||
// - 无明确微调诉求:直接 Done -> Deliver;
|
||||
// - 有明确微调诉求:进入 Execute。
|
||||
func RunRoughBuildNode(ctx context.Context, st *agentmodel.AgentGraphState) error {
|
||||
if st == nil {
|
||||
return fmt.Errorf("rough build node: state is nil")
|
||||
}
|
||||
|
||||
flowState := st.EnsureFlowState()
|
||||
emitter := st.EnsureChunkEmitter()
|
||||
|
||||
// 1. 推送状态:告知前端进入粗排环节。
|
||||
_ = emitter.EmitStatus(
|
||||
roughBuildStatusBlock,
|
||||
roughBuildStageName,
|
||||
"rough_building",
|
||||
"正在为你生成初始排课方案,请稍候。",
|
||||
true,
|
||||
)
|
||||
|
||||
// 2. 校验依赖。
|
||||
if st.Deps.RoughBuildFunc == nil {
|
||||
return fmt.Errorf("rough build node: RoughBuildFunc 未注入")
|
||||
}
|
||||
|
||||
// 3. 读取任务类 IDs。
|
||||
taskClassIDs := flowState.TaskClassIDs
|
||||
if len(taskClassIDs) == 0 {
|
||||
// 没有任务类 ID 时静默跳过粗排,直接进入执行阶段。
|
||||
flowState.Phase = agentmodel.PhaseExecuting
|
||||
flowState.NeedsRoughBuild = false
|
||||
flowState.NeedsRefineAfterRoughBuild = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. 粗排前强制刷新 ScheduleState,避免复用旧快照窗口。
|
||||
// 4.1 设计意图:当用户做“超前规划”时,窗口必须跟随本轮 task_class_ids,而不是沿用历史“当前周”窗口。
|
||||
// 4.2 做法:主动丢弃内存中的旧 state,让 EnsureScheduleState 走 provider 重新加载。
|
||||
// 4.3 失败策略:若任务类缺少有效起止日期,provider 会返回错误,由上层统一透传并让用户补齐字段。
|
||||
st.ScheduleState = nil
|
||||
st.OriginalScheduleState = nil
|
||||
|
||||
// 5. 加载 ScheduleState(含 DayMapping,用于坐标转换)。
|
||||
scheduleState, err := st.EnsureScheduleState(ctx)
|
||||
if err != nil {
|
||||
// 1. 当任务类时间窗缺失时,按“可恢复失败”收口:提示用户先补齐起止日期,再重试粗排。
|
||||
// 2. 不把这类输入缺失上抛为系统错误,避免整条链路直接 fallback 到普通聊天。
|
||||
if strings.Contains(err.Error(), "任务类缺少有效时间窗") {
|
||||
failureMessage := "开始智能编排前,我需要任务类的起止日期(start_date / end_date)。请先补齐时间窗,再让我继续排课。"
|
||||
_ = emitter.EmitStatus(
|
||||
roughBuildStatusBlock,
|
||||
roughBuildStageName,
|
||||
"rough_build_need_time_window",
|
||||
failureMessage,
|
||||
true,
|
||||
)
|
||||
flowState.NeedsRoughBuild = false
|
||||
flowState.Abort(
|
||||
roughBuildStageName,
|
||||
"rough_build_window_missing",
|
||||
failureMessage,
|
||||
err.Error(),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("rough build node: 加载日程状态失败: %w", err)
|
||||
}
|
||||
if scheduleState == nil {
|
||||
return fmt.Errorf("rough build node: ScheduleState 为空,无法执行粗排")
|
||||
}
|
||||
|
||||
// 6. 调用粗排算法。
|
||||
placements, err := st.Deps.RoughBuildFunc(ctx, flowState.UserID, taskClassIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rough build node: 粗排算法失败: %w", err)
|
||||
}
|
||||
|
||||
// 7. 把粗排结果写入 ScheduleState。
|
||||
applyStats := applyRoughBuildPlacements(scheduleState, placements)
|
||||
|
||||
// 7.1 标记本轮产生过日程变更,供 deliver 节点判断是否推送“排程完毕”卡片。
|
||||
if applyStats.AppliedCount > 0 {
|
||||
flowState.HasScheduleChanges = true
|
||||
}
|
||||
|
||||
// 8. 先校验粗排后是否仍有真实 pending。
|
||||
stillPending := countPendingTasks(scheduleState, taskClassIDs)
|
||||
log.Printf(
|
||||
"[DEBUG] rough_build scope_task_classes=%v placements=%d applied=%d day_mapping_miss=%d task_item_match_miss=%d pending_in_scope=%d total_tasks=%d window_days=%d",
|
||||
taskClassIDs,
|
||||
len(placements),
|
||||
applyStats.AppliedCount,
|
||||
applyStats.DayMappingMissCount,
|
||||
applyStats.TaskItemMatchMissCount,
|
||||
stillPending,
|
||||
len(scheduleState.Tasks),
|
||||
len(scheduleState.Window.DayMapping),
|
||||
)
|
||||
if applyStats.DayMappingMissCount > 0 {
|
||||
log.Printf(
|
||||
"[DEBUG] rough_build day_mapping_miss_samples=%v window=%s",
|
||||
applyStats.DayMappingMissSamples,
|
||||
summarizeRoughBuildWindow(scheduleState),
|
||||
)
|
||||
}
|
||||
if applyStats.TaskItemMatchMissCount > 0 {
|
||||
log.Printf(
|
||||
"[DEBUG] rough_build task_item_match_miss_samples=%v scoped_task_samples=%v",
|
||||
applyStats.TaskItemMatchMissSamples,
|
||||
collectScopedTaskSamples(scheduleState, taskClassIDs),
|
||||
)
|
||||
}
|
||||
if stillPending > 0 {
|
||||
failureMessage := fmt.Sprintf(
|
||||
"初始排课方案构建异常:粗排后仍有 %d 个任务未获得初始落位。按当前规则,本轮不进入微调,请检查粗排算法或任务数据。",
|
||||
stillPending,
|
||||
)
|
||||
_ = emitter.EmitStatus(
|
||||
roughBuildStatusBlock,
|
||||
roughBuildStageName,
|
||||
"rough_build_failed",
|
||||
failureMessage,
|
||||
true,
|
||||
)
|
||||
flowState.NeedsRoughBuild = false
|
||||
flowState.Abort(
|
||||
roughBuildStageName,
|
||||
"rough_build_pending_remaining",
|
||||
failureMessage,
|
||||
fmt.Sprintf("rough build finished with %d real pending tasks remaining", stillPending),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 8. 计算是否需要“粗排后立即微调”。
|
||||
//
|
||||
// 1. 只在“无计划直执行”链路下应用该止血分流;
|
||||
// 2. 有计划链路依旧进入 execute,避免改变既有 plan->execute 语义;
|
||||
// 3. chat 路由明确标记 needs_refine_after_rough_build=true 时才进微调。
|
||||
shouldRefineAfterRoughBuild := flowState.HasPlan() || flowState.NeedsRefineAfterRoughBuild
|
||||
|
||||
// 9. 推送完成状态(区分“继续微调”与“直接收口”两种路径)。
|
||||
doneStatus := "rough_build_done"
|
||||
doneMessage := fmt.Sprintf("初始排课方案已生成,共 %d 个任务已预排,进入微调阶段。", len(placements))
|
||||
if !shouldRefineAfterRoughBuild {
|
||||
doneStatus = "rough_build_done_no_refine"
|
||||
doneMessage = fmt.Sprintf("初始排课方案已生成,共 %d 个任务已预排。本轮按默认策略先结束;如需优化,请继续告诉我你的偏好。", len(placements))
|
||||
}
|
||||
_ = emitter.EmitStatus(
|
||||
roughBuildStatusBlock,
|
||||
roughBuildStageName,
|
||||
doneStatus,
|
||||
doneMessage,
|
||||
false,
|
||||
)
|
||||
|
||||
// 10. 把粗排完成信息写入 pinned context,让后续节点能拿到一致事实。
|
||||
|
||||
// 构造任务类 ID 字符串,供 pinned block 明确标注,避免 Execute LLM 因找不到 task_class_id 来源而 ask_user。
|
||||
idParts := make([]string, len(taskClassIDs))
|
||||
for i, id := range taskClassIDs {
|
||||
idParts[i] = strconv.Itoa(id)
|
||||
}
|
||||
idStr := strings.Join(idParts, ", ")
|
||||
|
||||
pinnedContent := fmt.Sprintf(
|
||||
"后端已自动运行粗排算法(任务类 ID:[%s]),初始排课方案已写入日程状态(共 %d 个任务已预排)。\n"+
|
||||
"这些预排任务已标记为 suggested,表示“可继续优化的建议落位”,不是待补排任务。\n"+
|
||||
"本轮不需要再调用 place,也无需再次触发粗排。",
|
||||
idStr, len(placements),
|
||||
)
|
||||
if shouldRefineAfterRoughBuild {
|
||||
pinnedContent += "\n请先调用 get_overview 查看整体分布,再使用 move / swap / unplace 微调不合理的位置。"
|
||||
} else {
|
||||
pinnedContent += "\n当前未收到明确微调偏好,流程将先收口;如需进一步优化,请基于本次结果提出调整要求。"
|
||||
}
|
||||
st.EnsureConversationContext().UpsertPinnedBlock(agentmodel.ContextBlock{
|
||||
Key: "rough_build_done",
|
||||
Title: "粗排已完成",
|
||||
Content: pinnedContent,
|
||||
})
|
||||
|
||||
// 11. 清除粗排标记,并按分流结果进入执行或直接收口。
|
||||
//
|
||||
// 1. 无明确微调诉求:直接标记 completed,graph 会路由到 deliver;
|
||||
// 2. 有明确微调诉求:进入 execute 节点继续工具微调;
|
||||
// 3. 无论哪条路径,都要重置粗排相关标记,避免污染后续轮次。
|
||||
flowState.NeedsRoughBuild = false
|
||||
flowState.NeedsRefineAfterRoughBuild = false
|
||||
if !shouldRefineAfterRoughBuild {
|
||||
flowState.ActiveOptimizeOnly = false
|
||||
flowState.Done()
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(flowState.OptimizationMode) == "" {
|
||||
flowState.OptimizationMode = "first_full"
|
||||
}
|
||||
// 1. 仅“粗排后自动进入微调”的链路打开主动优化专用模式。
|
||||
// 2. 该模式会把 execute 裁成 analyze_health + move + swap 的最小工具面,
|
||||
// 迫使 LLM 基于候选做选择,而不是重新全窗乱搜。
|
||||
// 3. 用户后续重开新请求时,会在 CommonState 的重置入口统一清掉这个标记。
|
||||
flowState.ActiveOptimizeOnly = true
|
||||
// 12. 粗排后进入 execute 微调时,补一条一次性 context hook。
|
||||
//
|
||||
// 1. 目的:即使这条链路不回 plan,也能在 execute 首轮拿到建议工具面(analyze + mutation)。
|
||||
// 2. 边界:这里只写“建议激活域/包”,不直接执行 context_tools_add,仍由 execute 按统一入口消费。
|
||||
// 3. 回退:hook 无效时 execute 会自动忽略并清空,不影响主流程。
|
||||
flowState.PendingContextHook = &agentmodel.ContextHook{
|
||||
Domain: agenttools.ToolDomainSchedule,
|
||||
Packs: []string{
|
||||
agenttools.ToolPackAnalyze,
|
||||
agenttools.ToolPackMutation,
|
||||
},
|
||||
Reason: "rough_build_post_refine",
|
||||
}
|
||||
flowState.Phase = agentmodel.PhaseExecuting
|
||||
return nil
|
||||
}
|
||||
|
||||
// countPendingTasks 统计粗排后仍无位置的待安排任务数。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 第一轮修复后,粗排成功会把任务直接标记为 suggested;
|
||||
// 2. 为兼容旧快照,仍按“pending 且 Slots 为空”认定真正未覆盖;
|
||||
// 3. 只要这里仍大于 0,就应视为粗排异常,而不是交给 LLM 补排。
|
||||
func countPendingTasks(state *schedule.ScheduleState, taskClassIDs []int) int {
|
||||
if state == nil {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for i := range state.Tasks {
|
||||
task := state.Tasks[i]
|
||||
if !schedule.IsPendingTask(task) {
|
||||
continue
|
||||
}
|
||||
if len(taskClassIDs) > 0 && !schedule.IsTaskInRequestedClassScope(task, taskClassIDs) {
|
||||
continue
|
||||
}
|
||||
if schedule.IsPendingTask(task) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// applyRoughBuildPlacements 把粗排结果写入 ScheduleState 对应任务的 Slots。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 通过 task_item_id(SourceID)定位任务;
|
||||
// 2. 用 DayMapping 把 (week, dayOfWeek) 转为 day_index;
|
||||
// 3. 对成功落位的任务写入 Slots,并显式标记为 suggested;
|
||||
// 4. suggested 表示“粗排建议位”,后续可用 move/swap/unplace 微调;
|
||||
// 5. 转换失败的条目静默跳过,不中断整体流程。
|
||||
func applyRoughBuildPlacements(
|
||||
state *schedule.ScheduleState,
|
||||
placements []agentmodel.RoughBuildPlacement,
|
||||
) roughBuildApplyStats {
|
||||
stats := roughBuildApplyStats{}
|
||||
if state == nil {
|
||||
return stats
|
||||
}
|
||||
|
||||
taskIndexByItemID := make(map[int][]int)
|
||||
for i := range state.Tasks {
|
||||
task := state.Tasks[i]
|
||||
if task.Source != "task_item" {
|
||||
continue
|
||||
}
|
||||
taskIndexByItemID[task.SourceID] = append(taskIndexByItemID[task.SourceID], i)
|
||||
}
|
||||
|
||||
for _, p := range placements {
|
||||
day, ok := state.WeekDayToDay(p.Week, p.DayOfWeek)
|
||||
if !ok {
|
||||
stats.DayMappingMissCount++
|
||||
stats.DayMappingMissSamples = appendPlacementSample(stats.DayMappingMissSamples, p)
|
||||
continue // DayMapping 里没有对应 day,跳过
|
||||
}
|
||||
|
||||
matched := false
|
||||
for _, index := range taskIndexByItemID[p.TaskItemID] {
|
||||
t := &state.Tasks[index]
|
||||
t.Slots = []schedule.TaskSlot{
|
||||
{Day: day, SlotStart: p.SectionFrom, SlotEnd: p.SectionTo},
|
||||
}
|
||||
t.Status = schedule.TaskStatusSuggested
|
||||
stats.AppliedCount++
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
if !matched {
|
||||
stats.TaskItemMatchMissCount++
|
||||
stats.TaskItemMatchMissSamples = appendPlacementSample(stats.TaskItemMatchMissSamples, p)
|
||||
}
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
// appendPlacementSample 记录有限数量的 miss 样本,避免 debug 日志爆量。
|
||||
func appendPlacementSample(samples []string, placement agentmodel.RoughBuildPlacement) []string {
|
||||
if len(samples) >= roughBuildSampleLimit {
|
||||
return samples
|
||||
}
|
||||
return append(samples, fmt.Sprintf(
|
||||
"task_item_id=%d week=%d day=%d sections=%d-%d",
|
||||
placement.TaskItemID,
|
||||
placement.Week,
|
||||
placement.DayOfWeek,
|
||||
placement.SectionFrom,
|
||||
placement.SectionTo,
|
||||
))
|
||||
}
|
||||
|
||||
// summarizeRoughBuildWindow 提供 DayMapping 的紧凑摘要,便于判断窗口是否退化到错误周。
|
||||
func summarizeRoughBuildWindow(state *schedule.ScheduleState) string {
|
||||
if state == nil || len(state.Window.DayMapping) == 0 {
|
||||
return "empty"
|
||||
}
|
||||
first := state.Window.DayMapping[0]
|
||||
last := state.Window.DayMapping[len(state.Window.DayMapping)-1]
|
||||
return fmt.Sprintf(
|
||||
"days=%d first=W%dD%d last=W%dD%d",
|
||||
len(state.Window.DayMapping),
|
||||
first.Week,
|
||||
first.DayOfWeek,
|
||||
last.Week,
|
||||
last.DayOfWeek,
|
||||
)
|
||||
}
|
||||
|
||||
// collectScopedTaskSamples 提供当前 state 中可用于匹配的 task_item 样本,便于排查 ID 对不上。
|
||||
func collectScopedTaskSamples(state *schedule.ScheduleState, taskClassIDs []int) []string {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
samples := make([]string, 0, roughBuildSampleLimit)
|
||||
for i := range state.Tasks {
|
||||
task := state.Tasks[i]
|
||||
if task.Source != "task_item" {
|
||||
continue
|
||||
}
|
||||
if len(taskClassIDs) > 0 && !schedule.IsTaskInRequestedClassScope(task, taskClassIDs) {
|
||||
continue
|
||||
}
|
||||
samples = append(samples, fmt.Sprintf(
|
||||
"source_id=%d task_class_id=%d status=%s name=%q",
|
||||
task.SourceID,
|
||||
task.TaskClassID,
|
||||
task.Status,
|
||||
task.Name,
|
||||
))
|
||||
if len(samples) >= roughBuildSampleLimit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return samples
|
||||
}
|
||||
21
backend/services/agent/node/speak_text.go
Normal file
21
backend/services/agent/node/speak_text.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// listItemRe 匹配被粘连在一起的列表序号,用于正文归一化时自动补换行。
|
||||
var listItemRe = regexp.MustCompile(`([^\n])([2-9][\.、]\s)`)
|
||||
|
||||
// normalizeSpeak 统一整理要展示给用户的正文。
|
||||
func normalizeSpeak(speak string) string {
|
||||
speak = strings.TrimSpace(speak)
|
||||
if speak == "" {
|
||||
return speak
|
||||
}
|
||||
if !strings.Contains(speak, "\n") {
|
||||
speak = listItemRe.ReplaceAllString(speak, "$1\n$2")
|
||||
}
|
||||
return speak + "\n"
|
||||
}
|
||||
301
backend/services/agent/node/unified_compact.go
Normal file
301
backend/services/agent/node/unified_compact.go
Normal file
@@ -0,0 +1,301 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/pkg"
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentprompt "github.com/LoveLosita/smartflow/backend/services/agent/prompt"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// UnifiedCompactInput 是统一压缩入口的参数。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 从 ExecuteNodeInput 中提取压缩所需的公共字段,消除对 Execute 的直接依赖;
|
||||
// 2. 各节点(Plan/Chat/Deliver)构造此参数时从自己的 NodeInput 中提取对应字段;
|
||||
// 3. StageName 和 StatusBlockID 用于区分日志来源和 SSE 状态推送。
|
||||
type UnifiedCompactInput struct {
|
||||
// Client 用于调用 LLM 压缩 msg1/msg2。
|
||||
Client *llmservice.Client
|
||||
// CompactionStore 用于持久化压缩摘要和 token 统计,为 nil 时跳过持久化。
|
||||
CompactionStore agentmodel.CompactionStore
|
||||
// FlowState 提供 userID / chatID / roundUsed 等定位信息。
|
||||
FlowState *agentmodel.CommonState
|
||||
// Emitter 用于推送压缩进度 SSE 事件。
|
||||
Emitter *agentstream.ChunkEmitter
|
||||
// StageName 标识当前阶段(如 "execute"/"plan"/"chat"/"deliver"),用于日志和缓存 key。
|
||||
StageName string
|
||||
// StatusBlockID 是 SSE 状态推送的 block ID,各节点使用自己的 block ID。
|
||||
StatusBlockID string
|
||||
}
|
||||
|
||||
// compactUnifiedMessagesIfNeeded 检查统一消息结构的 token 预算,
|
||||
// 超限时对 msg1(历史对话)和 msg2(阶段工作区)执行 LLM 压缩。
|
||||
//
|
||||
// 消息布局约定(由 buildUnifiedStageMessages 返回):
|
||||
//
|
||||
// [0] system — msg0: 系统规则 + 工具简表
|
||||
// [1] assistant — msg1: 历史对话上下文
|
||||
// [2] assistant — msg2: 阶段工作区(Execute=ReAct Loop,其余="暂无")
|
||||
// [3] system — msg3: 阶段状态 + 记忆 + 指令
|
||||
//
|
||||
// 压缩策略:
|
||||
// 1. msg1 超过可用预算一半时触发 LLM 压缩(合并已有摘要 + 新内容);
|
||||
// 2. msg1 压缩后仍超限,则对 msg2 也做 LLM 压缩;
|
||||
// 3. 压缩结果持久化到 CompactionStore,下一轮可复用摘要避免重复计算。
|
||||
func compactUnifiedMessagesIfNeeded(
|
||||
ctx context.Context,
|
||||
messages []*schema.Message,
|
||||
input UnifiedCompactInput,
|
||||
) []*schema.Message {
|
||||
if input.FlowState == nil {
|
||||
log.Printf("[COMPACT:%s] FlowState is nil, skip token stats refresh", input.StageName)
|
||||
return messages
|
||||
}
|
||||
|
||||
// 1. 非严格 4 段式时,退化成按角色汇总的统计,确保 context_token_stats 仍然刷新。
|
||||
if len(messages) != 4 {
|
||||
breakdown := estimateFallbackStageTokenBreakdown(messages)
|
||||
log.Printf(
|
||||
"[COMPACT:%s] fallback token stats refresh: total=%d budget=%d count=%d (msg0=%d msg1=%d msg2=%d msg3=%d)",
|
||||
input.StageName, breakdown.Total, breakdown.Budget, len(messages),
|
||||
breakdown.Msg0, breakdown.Msg1, breakdown.Msg2, breakdown.Msg3,
|
||||
)
|
||||
saveUnifiedTokenStats(ctx, input, breakdown)
|
||||
return messages
|
||||
}
|
||||
|
||||
// 2. 提取四条消息的文本内容。
|
||||
msg0 := messages[0].Content
|
||||
msg1 := messages[1].Content
|
||||
msg2 := messages[2].Content
|
||||
msg3 := messages[3].Content
|
||||
|
||||
// 3. Token 预算检查。
|
||||
breakdown, overBudget, needCompactMsg1, needCompactMsg2 := pkg.CheckStageTokenBudget(msg0, msg1, msg2, msg3)
|
||||
|
||||
log.Printf(
|
||||
"[COMPACT:%s] token budget check: total=%d budget=%d over=%v compactMsg1=%v compactMsg2=%v (msg0=%d msg1=%d msg2=%d msg3=%d)",
|
||||
input.StageName, breakdown.Total, breakdown.Budget, overBudget, needCompactMsg1, needCompactMsg2,
|
||||
breakdown.Msg0, breakdown.Msg1, breakdown.Msg2, breakdown.Msg3,
|
||||
)
|
||||
|
||||
if !overBudget {
|
||||
// 4. 未超限,记录 token 分布后直接返回。
|
||||
saveUnifiedTokenStats(ctx, input, breakdown)
|
||||
return messages
|
||||
}
|
||||
|
||||
// 5. msg1 压缩(历史对话 → LLM 摘要)。
|
||||
if needCompactMsg1 {
|
||||
msg1 = compactUnifiedMsg1(ctx, input, msg1)
|
||||
messages[1].Content = msg1
|
||||
// 压缩 msg1 后重算预算。
|
||||
breakdown = pkg.EstimateStageMessagesTokens(msg0, msg1, msg2, msg3)
|
||||
}
|
||||
|
||||
// 6. msg2 压缩(阶段工作区 → LLM 摘要)。
|
||||
if needCompactMsg2 || breakdown.Total > pkg.StageTokenBudget {
|
||||
msg2 = compactUnifiedMsg2(ctx, input, msg2)
|
||||
messages[2].Content = msg2
|
||||
breakdown = pkg.EstimateStageMessagesTokens(msg0, msg1, msg2, msg3)
|
||||
}
|
||||
|
||||
// 7. 记录最终 token 分布。
|
||||
saveUnifiedTokenStats(ctx, input, breakdown)
|
||||
|
||||
log.Printf(
|
||||
"[COMPACT:%s] after compaction: total=%d budget=%d (msg0=%d msg1=%d msg2=%d msg3=%d)",
|
||||
input.StageName, breakdown.Total, breakdown.Budget,
|
||||
breakdown.Msg0, breakdown.Msg1, breakdown.Msg2, breakdown.Msg3,
|
||||
)
|
||||
return messages
|
||||
}
|
||||
|
||||
// estimateFallbackStageTokenBreakdown 在非统一 4 段式场景下按消息角色做近似统计。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 先按消息类型汇总 token,保证总量准确;
|
||||
// 2. 再把最后一个 user 消息尽量视作 msg3,保留阶段指令语义;
|
||||
// 3. 其他历史内容归入 msg1 / msg2,确保上下文统计不会因为结构不标准而断更。
|
||||
func estimateFallbackStageTokenBreakdown(messages []*schema.Message) pkg.StageTokenBreakdown {
|
||||
breakdown := pkg.StageTokenBreakdown{Budget: pkg.StageTokenBudget}
|
||||
if len(messages) == 0 {
|
||||
return breakdown
|
||||
}
|
||||
|
||||
lastUserIndex := -1
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
msg := messages[i]
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
if msg.Role == schema.User {
|
||||
lastUserIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for i, msg := range messages {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
tokens := pkg.EstimateMessageTokens(msg)
|
||||
breakdown.Total += tokens
|
||||
|
||||
switch msg.Role {
|
||||
case schema.System:
|
||||
breakdown.Msg0 += tokens
|
||||
case schema.User:
|
||||
if i == lastUserIndex {
|
||||
breakdown.Msg3 += tokens
|
||||
} else {
|
||||
breakdown.Msg1 += tokens
|
||||
}
|
||||
case schema.Tool:
|
||||
breakdown.Msg2 += tokens
|
||||
case schema.Assistant:
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
breakdown.Msg2 += tokens
|
||||
} else {
|
||||
breakdown.Msg1 += tokens
|
||||
}
|
||||
default:
|
||||
breakdown.Msg1 += tokens
|
||||
}
|
||||
}
|
||||
|
||||
return breakdown
|
||||
}
|
||||
|
||||
// compactUnifiedMsg1 对 msg1(历史对话)执行 LLM 压缩。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. CompactionStore 为 nil 时跳过(测试环境 / 骨架期);
|
||||
// 2. 先加载该阶段已有的压缩摘要,与当前 msg1 合并后调 LLM 压缩;
|
||||
// 3. 压缩失败时降级为原始文本,不中断主流程;
|
||||
// 4. 压缩成功后持久化新摘要,供下一轮复用。
|
||||
func compactUnifiedMsg1(
|
||||
ctx context.Context,
|
||||
input UnifiedCompactInput,
|
||||
msg1 string,
|
||||
) string {
|
||||
// 1. CompactionStore 为 nil 时无法加载/保存摘要,跳过压缩。
|
||||
if input.CompactionStore == nil {
|
||||
log.Printf("[COMPACT:%s] CompactionStore is nil, skip msg1 compaction", input.StageName)
|
||||
return msg1
|
||||
}
|
||||
|
||||
// 2. 加载该阶段已有的压缩摘要(可能为空)。
|
||||
existingSummary, _, err := input.CompactionStore.LoadStageCompaction(ctx, input.FlowState.UserID, input.FlowState.ConversationID, input.StageName)
|
||||
if err != nil {
|
||||
log.Printf("[COMPACT:%s] load existing compaction failed: %v, proceed without cache", input.StageName, err)
|
||||
}
|
||||
|
||||
// 3. SSE: 压缩开始。
|
||||
tokenBefore := pkg.EstimateTextTokens(msg1)
|
||||
_ = input.Emitter.EmitStatus(
|
||||
input.StatusBlockID, input.StageName, "context_compact_start",
|
||||
fmt.Sprintf("正在压缩对话历史(%d tokens)...", tokenBefore),
|
||||
false,
|
||||
)
|
||||
|
||||
// 4. 调用 LLM 压缩:将 msg1 全文 + 已有摘要合并为一份紧凑摘要。
|
||||
newSummary, err := agentprompt.CompactMsg1(ctx, input.Client, msg1, existingSummary)
|
||||
if err != nil {
|
||||
log.Printf("[COMPACT:%s] compact msg1 failed: %v", input.StageName, err)
|
||||
_ = input.Emitter.EmitStatus(
|
||||
input.StatusBlockID, input.StageName, "context_compact_done",
|
||||
"对话历史压缩失败,使用原始文本",
|
||||
false,
|
||||
)
|
||||
return msg1
|
||||
}
|
||||
|
||||
// 5. SSE: 压缩完成。
|
||||
tokenAfter := pkg.EstimateTextTokens(newSummary)
|
||||
_ = input.Emitter.EmitStatus(
|
||||
input.StatusBlockID, input.StageName, "context_compact_done",
|
||||
fmt.Sprintf("对话历史已压缩:%d → %d tokens", tokenBefore, tokenAfter),
|
||||
false,
|
||||
)
|
||||
|
||||
// 6. 持久化压缩结果,下一轮可直接复用摘要。
|
||||
if err := input.CompactionStore.SaveStageCompaction(ctx, input.FlowState.UserID, input.FlowState.ConversationID, input.StageName, newSummary, input.FlowState.RoundUsed); err != nil {
|
||||
log.Printf("[COMPACT:%s] save compaction failed: %v", input.StageName, err)
|
||||
}
|
||||
|
||||
return newSummary
|
||||
}
|
||||
|
||||
// compactUnifiedMsg2 对 msg2(阶段工作区)执行 LLM 压缩。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 非 Execute 阶段的 msg2 通常是"暂无",压缩无意义但不会出错;
|
||||
// 2. Execute 阶段的 msg2 包含 ReAct loop 记录,压缩可显著节省 token;
|
||||
// 3. 压缩失败时降级为原始文本,不中断主流程。
|
||||
func compactUnifiedMsg2(
|
||||
ctx context.Context,
|
||||
input UnifiedCompactInput,
|
||||
msg2 string,
|
||||
) string {
|
||||
// 1. SSE: 压缩开始。
|
||||
tokenBefore := pkg.EstimateTextTokens(msg2)
|
||||
_ = input.Emitter.EmitStatus(
|
||||
input.StatusBlockID, input.StageName, "context_compact_start",
|
||||
fmt.Sprintf("正在压缩执行记录(%d tokens)...", tokenBefore),
|
||||
false,
|
||||
)
|
||||
|
||||
// 2. 调用 LLM 压缩。
|
||||
compressed, err := agentprompt.CompactMsg2(ctx, input.Client, msg2)
|
||||
if err != nil {
|
||||
log.Printf("[COMPACT:%s] compact msg2 failed: %v", input.StageName, err)
|
||||
_ = input.Emitter.EmitStatus(
|
||||
input.StatusBlockID, input.StageName, "context_compact_done",
|
||||
"执行记录压缩失败,使用原始文本",
|
||||
false,
|
||||
)
|
||||
return msg2
|
||||
}
|
||||
|
||||
// 3. SSE: 压缩完成。
|
||||
tokenAfter := pkg.EstimateTextTokens(compressed)
|
||||
_ = input.Emitter.EmitStatus(
|
||||
input.StatusBlockID, input.StageName, "context_compact_done",
|
||||
fmt.Sprintf("执行记录已压缩:%d → %d tokens", tokenBefore, tokenAfter),
|
||||
false,
|
||||
)
|
||||
|
||||
return compressed
|
||||
}
|
||||
|
||||
// saveUnifiedTokenStats 持久化当前 token 分布到 DB。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. CompactionStore 为 nil 时跳过(测试环境 / 骨架期);
|
||||
// 2. 序列化失败只记日志,不中断主流程;
|
||||
// 3. 写入失败只记日志,不中断主流程。
|
||||
func saveUnifiedTokenStats(
|
||||
ctx context.Context,
|
||||
input UnifiedCompactInput,
|
||||
breakdown pkg.StageTokenBreakdown,
|
||||
) {
|
||||
if input.CompactionStore == nil || input.FlowState == nil {
|
||||
return
|
||||
}
|
||||
statsJSON, err := json.Marshal(breakdown)
|
||||
if err != nil {
|
||||
log.Printf("[COMPACT:%s] marshal token stats failed: %v", input.StageName, err)
|
||||
return
|
||||
}
|
||||
if err := input.CompactionStore.SaveContextTokenStats(ctx, input.FlowState.UserID, input.FlowState.ConversationID, string(statsJSON)); err != nil {
|
||||
log.Printf("[COMPACT:%s] save token stats failed: %v", input.StageName, err)
|
||||
}
|
||||
}
|
||||
37
backend/services/agent/node/visible_message.go
Normal file
37
backend/services/agent/node/visible_message.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// persistVisibleAssistantMessage 负责把“真正要展示给用户”的 assistant 文本交给 service 层持久化。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只处理可见的 assistant 消息,不处理内部纠错提示、工具调用结果和纯状态文案;
|
||||
// 2. 持久化失败只记日志,不反向中断节点主流程,避免“已经对外输出但后端补写失败”时把用户请求打断;
|
||||
// 3. 具体的 Redis / MySQL / 乐观缓存写入由 service 回调统一完成。
|
||||
func persistVisibleAssistantMessage(
|
||||
ctx context.Context,
|
||||
persist agentmodel.PersistVisibleMessageFunc,
|
||||
state *agentmodel.CommonState,
|
||||
msg *schema.Message,
|
||||
) {
|
||||
if persist == nil || state == nil || msg == nil {
|
||||
return
|
||||
}
|
||||
|
||||
role := strings.TrimSpace(string(msg.Role))
|
||||
content := strings.TrimSpace(msg.Content)
|
||||
if role != string(schema.Assistant) || content == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if err := persist(ctx, state, msg); err != nil {
|
||||
log.Printf("[WARN] persist visible assistant message failed chat=%s phase=%s err=%v", state.ConversationID, state.Phase, err)
|
||||
}
|
||||
}
|
||||
241
backend/services/agent/prompt/base.go
Normal file
241
backend/services/agent/prompt/base.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// buildStageMessages 组装某个阶段通用的 messages。
|
||||
//
|
||||
// 消息排列策略(利用 LLM 近因效应):
|
||||
// 1. system prompt(角色 + 阶段规则)— 始终最顶部,定义基本身份;
|
||||
// 2. tool schemas(能力边界)— 稳定参考信息,放在 history 前即可;
|
||||
// 3. history(对话历史、工具调用、修正反馈)— 按时间顺序排列;
|
||||
// 4. pinned blocks(当前计划、当前步骤、粗排结果等最新约束)— 紧贴 user prompt,
|
||||
// 利用近因效应让 LLM 优先关注本轮最相关的约束,而非被历史消息分散注意力;
|
||||
// 5. user prompt(阶段性指令)— 始终在末尾,是本轮回答的核心触发。
|
||||
func buildStageMessages(stageSystemPrompt string, ctx *agentmodel.ConversationContext, runtimeUserPrompt string) []*schema.Message {
|
||||
messages := make([]*schema.Message, 0, 4)
|
||||
|
||||
// 1. 合并 system prompt:基础角色约束 + 阶段规则,始终在最顶部。
|
||||
mergedSystemPrompt := mergeSystemPrompts(ctx, stageSystemPrompt)
|
||||
if mergedSystemPrompt != "" {
|
||||
messages = append(messages, schema.SystemMessage(mergedSystemPrompt))
|
||||
}
|
||||
|
||||
// 2. 工具摘要:稳定参考信息,放在 history 前即可。
|
||||
if toolText := renderToolSchemas(ctx); toolText != "" {
|
||||
messages = append(messages, schema.SystemMessage(toolText))
|
||||
}
|
||||
|
||||
// 3. 对话历史:按时间顺序,包含工具调用结果和修正反馈。
|
||||
if ctx != nil {
|
||||
history := ctx.HistorySnapshot()
|
||||
if len(history) > 0 {
|
||||
// 兼容旧快照:裸 Tool 消息(无 ToolCallID)违反 OpenAI 兼容 API 格式约束,
|
||||
// 会触发 API 拒绝请求导致连接断开。
|
||||
// 这里将裸 Tool 消息降级为 User 消息,保证向后兼容。
|
||||
for i, msg := range history {
|
||||
if msg.Role == schema.Tool && msg.ToolCallID == "" {
|
||||
history[i] = &schema.Message{
|
||||
Role: schema.User,
|
||||
Content: fmt.Sprintf("[工具执行结果]\n%s", msg.Content),
|
||||
}
|
||||
}
|
||||
}
|
||||
messages = append(messages, history...)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 置顶上下文块:当前计划、当前步骤、粗排结果等最新约束。
|
||||
// 放在 history 之后、user prompt 之前,利用 LLM 近因效应提升对最新约束的注意力。
|
||||
if pinnedText := renderPinnedBlocks(ctx); pinnedText != "" {
|
||||
messages = append(messages, schema.SystemMessage(pinnedText))
|
||||
}
|
||||
|
||||
// 5. 阶段性用户提示词:始终在末尾,是本轮回答的核心触发。
|
||||
runtimeUserPrompt = strings.TrimSpace(runtimeUserPrompt)
|
||||
if runtimeUserPrompt != "" {
|
||||
messages = append(messages, schema.UserMessage(runtimeUserPrompt))
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
// renderStateSummary 把当前流程状态渲染成简洁文本。
|
||||
func renderStateSummary(state *agentmodel.CommonState) string {
|
||||
if state == nil {
|
||||
return "当前状态:state 缺失,请先做兜底处理。"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
current, total := state.PlanProgress()
|
||||
|
||||
sb.WriteString(fmt.Sprintf("当前阶段:%s\n", state.Phase))
|
||||
sb.WriteString(fmt.Sprintf("当前轮次:%d/%d\n", state.RoundUsed, state.MaxRounds))
|
||||
if state.HasTerminalOutcome() && state.TerminalOutcome != nil {
|
||||
sb.WriteString(fmt.Sprintf("终止结果:%s\n", state.TerminalOutcome.Status))
|
||||
if strings.TrimSpace(state.TerminalOutcome.Stage) != "" {
|
||||
sb.WriteString(fmt.Sprintf("终止阶段:%s\n", state.TerminalOutcome.Stage))
|
||||
}
|
||||
if strings.TrimSpace(state.TerminalOutcome.Code) != "" {
|
||||
sb.WriteString(fmt.Sprintf("终止代码:%s\n", state.TerminalOutcome.Code))
|
||||
}
|
||||
if strings.TrimSpace(state.TerminalOutcome.UserMessage) != "" {
|
||||
sb.WriteString(fmt.Sprintf("终止说明:%s\n", state.TerminalOutcome.UserMessage))
|
||||
}
|
||||
}
|
||||
|
||||
if !state.HasPlan() {
|
||||
sb.WriteString("当前完整 plan:暂无。\n")
|
||||
} else {
|
||||
sb.WriteString("当前完整 plan:\n")
|
||||
for i, step := range state.PlanSteps {
|
||||
sb.WriteString(fmt.Sprintf("%d. %s\n", i+1, strings.TrimSpace(step.Content)))
|
||||
if strings.TrimSpace(step.DoneWhen) != "" {
|
||||
sb.WriteString(fmt.Sprintf(" 完成判定:%s\n", strings.TrimSpace(step.DoneWhen)))
|
||||
}
|
||||
}
|
||||
|
||||
if step, ok := state.CurrentPlanStep(); ok {
|
||||
sb.WriteString(fmt.Sprintf("当前步骤进度:%d/%d\n", current, total))
|
||||
sb.WriteString("当前步骤内容:\n")
|
||||
sb.WriteString(strings.TrimSpace(step.Content))
|
||||
sb.WriteString("\n")
|
||||
if strings.TrimSpace(step.DoneWhen) != "" {
|
||||
sb.WriteString("当前步骤完成判定:\n")
|
||||
sb.WriteString(strings.TrimSpace(step.DoneWhen))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
} else {
|
||||
sb.WriteString("当前步骤进度:暂时无有效当前步骤。\n")
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染任务类约束元数据(如有),帮助 LLM 了解排程范围和策略,避免追问已有信息。
|
||||
if len(state.TaskClasses) > 0 {
|
||||
sb.WriteString("\n本次排课涉及的任务类约束:\n")
|
||||
for _, tc := range state.TaskClasses {
|
||||
line := fmt.Sprintf("- [ID=%d] %s:策略=%s,总时段预算=%d", tc.ID, tc.Name, tc.Strategy, tc.TotalSlots)
|
||||
if tc.StartDate != "" || tc.EndDate != "" {
|
||||
line += fmt.Sprintf(",日期范围=%s ~ %s", tc.StartDate, tc.EndDate)
|
||||
}
|
||||
if tc.SubjectType != "" || tc.DifficultyLevel != "" || tc.CognitiveIntensity != "" {
|
||||
line += fmt.Sprintf(",语义画像=%s/%s/%s",
|
||||
defaultSemanticValue(tc.SubjectType),
|
||||
defaultSemanticValue(tc.DifficultyLevel),
|
||||
defaultSemanticValue(tc.CognitiveIntensity),
|
||||
)
|
||||
}
|
||||
if tc.AllowFillerCourse {
|
||||
line += ",允许嵌入水课"
|
||||
}
|
||||
if len(tc.ExcludedSlots) > 0 {
|
||||
line += fmt.Sprintf(",排除时段=%v", tc.ExcludedSlots)
|
||||
}
|
||||
if len(tc.ExcludedDaysOfWeek) > 0 {
|
||||
line += fmt.Sprintf(",排除星期=%v", tc.ExcludedDaysOfWeek)
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func defaultSemanticValue(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return "未标注"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// renderPinnedBlocks 把 ConversationContext 中的置顶块渲染成独立的 system 文本。
|
||||
func renderPinnedBlocks(ctx *agentmodel.ConversationContext) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
blocks := ctx.PinnedBlocksSnapshot()
|
||||
if len(blocks) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("以下是后端置顶注入的上下文,请优先遵守:\n")
|
||||
for _, block := range blocks {
|
||||
title := strings.TrimSpace(block.Title)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(block.Key)
|
||||
}
|
||||
if title != "" {
|
||||
sb.WriteString("【")
|
||||
sb.WriteString(title)
|
||||
sb.WriteString("】\n")
|
||||
}
|
||||
sb.WriteString(strings.TrimSpace(block.Content))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// renderToolSchemas 把工具摘要渲染成独立文本块。
|
||||
func renderToolSchemas(ctx *agentmodel.ConversationContext) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
schemas := ctx.ToolSchemasSnapshot()
|
||||
if len(schemas) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("以下是当前可用工具摘要,仅供你在规划时参考能力边界:\n")
|
||||
for _, item := range schemas {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
desc := strings.TrimSpace(item.Desc)
|
||||
schemaText := strings.TrimSpace(item.SchemaText)
|
||||
|
||||
if name != "" {
|
||||
sb.WriteString("- 工具名:")
|
||||
sb.WriteString(name)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if desc != "" {
|
||||
sb.WriteString(" 说明:")
|
||||
sb.WriteString(desc)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if schemaText != "" {
|
||||
sb.WriteString(" 参数摘要:")
|
||||
sb.WriteString(schemaText)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
func mergeSystemPrompts(ctx *agentmodel.ConversationContext, stageSystemPrompt string) string {
|
||||
base := ""
|
||||
if ctx != nil {
|
||||
base = strings.TrimSpace(ctx.SystemPrompt)
|
||||
}
|
||||
stageSystemPrompt = strings.TrimSpace(stageSystemPrompt)
|
||||
|
||||
switch {
|
||||
case base == "" && stageSystemPrompt == "":
|
||||
return ""
|
||||
case base == "":
|
||||
return stageSystemPrompt
|
||||
case stageSystemPrompt == "":
|
||||
return base
|
||||
default:
|
||||
return base + "\n\n" + stageSystemPrompt
|
||||
}
|
||||
}
|
||||
169
backend/services/agent/prompt/chat.go
Normal file
169
backend/services/agent/prompt/chat.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const chatRoutingSystemPrompt = `
|
||||
你是 SmartMate 的聊天路由助手。SmartMate 是时伴(SmartMate)的中文 AI 排程伙伴,面向大学生提供陪伴式日程管理与日常协助;它擅长日程安排、任务管理与学习规划,但不只会做排程。你的回复必须以路由控制码开头,控制码后紧跟用户可见的内容。
|
||||
|
||||
路由规则:
|
||||
- direct_reply:纯闲聊、简单问答、轻量生活建议、打招呼、感谢等不需要工具、也不需要长链路思考的请求。控制码后直接输出完整回复。
|
||||
- quick_task:用户明确想记录/添加一个待办或提醒(如"记一下""提醒我""帮我记"),或查看/筛选任务列表(如"我有什么任务""待办清单""最近急事""今天/明天/本周有什么事要做")。该路由走轻量快捷路径,延迟低、废话少。控制码后不要输出任何内容。
|
||||
- execute:需要用工具处理的日程编排请求(明确查课表/日程块、移动课程、排课等),但不需要先制定计划。控制码后输出简短确认。
|
||||
- deep_answer:复杂问题但不需要工具(如分析建议、知识解释、方案比较、深度讨论等),需要深度思考后回答。控制码后不要输出任何占位过渡语,后端会直接进入第二次正式回答。
|
||||
- plan:用户明确要求先制定计划,或涉及多阶段复杂规划。控制码后输出简短确认。
|
||||
|
||||
quick_task 判别要点:
|
||||
- 用户明确要"记/添加/提醒"一个待办 → quick_task
|
||||
- 用户要查看/筛选/列出任务清单 → quick_task
|
||||
- 用户问"今天/明天/本周有什么待办/任务/事情要做"这类时间窗任务查询 → quick_task
|
||||
- 用户明确在查课表/日程块、排课、移动安排 → execute
|
||||
- 但如果用户同时提了日程排布(如"把明天的课调一下,再记一下周五开会"),混合操作走 execute
|
||||
- 如果信息不足(如"帮我记一下"但没说记什么),走 direct_reply 追问
|
||||
|
||||
任务类设计路由要点:
|
||||
- 普通"创建/修改任务类配置(task class)"默认走 execute(由 execute 负责补字段与写入)。
|
||||
- 仅当用户明确要"补课程学习资料/学习建议/学习路径(需要外部知识)"时,走 plan(后续可使用 web_search)。
|
||||
- 考试时间、DDL、课程具体时间安排、个人可用时段等时间信息,必须向用户本人确认,不能作为 web 搜索补齐目标。
|
||||
|
||||
通用回答约束:
|
||||
- 非日程、非任务类问题,只要不需要工具,也应当正常回答。
|
||||
- 不要因为用户的问题不涉及排程,就说自己“只能处理日程/任务安排”。
|
||||
- 不要把普通问答、生活建议、开放式讨论,硬拐成排程请求。
|
||||
- route=direct_reply 时,控制码后的可见内容应直接回应用户问题,而不是先讲能力边界。
|
||||
- route=deep_answer 时,只输出控制码即可,不要补“让我想想”“这是个好问题”之类的占位话术。
|
||||
|
||||
粗排判断:当用户意图包含"批量安排/排课/把任务类排进日程"等批量调度需求时,可设置 rough_build=true;后端会结合真实请求范围决定是否真正进入粗排。
|
||||
二次粗排约束(强约束):
|
||||
- 若上下文已出现 rough_build_done,且用户未明确要求"重新粗排/从头重排",必须设置 rough_build=false。
|
||||
- "移动/微调/优化/均匀化/调顺序"等请求默认视为 refine,不得再次触发 rough build。
|
||||
粗排后微调判断:
|
||||
- 仅当 rough_build=true 时才判断 refine。
|
||||
- 默认策略:首次粗排完成后应进入微调(refine=true),按中位标准做主动优化。
|
||||
- 仅当用户明确表达"只要初稿/先排进去别优化/先不微调/排完就收口"时,才设 refine=false。
|
||||
顺序授权判断:
|
||||
- reorder 仅在用户明确说明"允许打乱顺序/顺序不重要"时才为 true。
|
||||
- 用户明确要求"保持顺序/不要打乱"时必须为 false。
|
||||
- 若用户未明确提及顺序,一律为 false。
|
||||
深度思考判断:
|
||||
- thinking 仅在 route=execute 时有效。
|
||||
- 当用户请求涉及复杂推理、多条件约束、需要深度分析后才能执行的操作时,设 thinking=true。
|
||||
- 简单查询、单步操作设 thinking=false。
|
||||
|
||||
输出格式(严格两段式):
|
||||
第一段(控制码,用户不可见,后端会截取):
|
||||
<SMARTFLOW_ROUTE nonce="给定nonce" route="direct_reply|execute|deep_answer|plan|quick_task" rough_build="false" refine="false" reorder="false" thinking="false"/>
|
||||
第二段(紧接控制码之后,用户可见):
|
||||
根据路由输出对应内容。
|
||||
|
||||
属性说明(仅 route=execute 时有效,其余路由省略这些属性):
|
||||
- rough_build:是否需要粗排
|
||||
- refine:粗排后是否需要微调
|
||||
- reorder:是否允许打乱顺序
|
||||
- thinking:后续执行阶段是否需要深度思考
|
||||
|
||||
合法示例:
|
||||
|
||||
<SMARTFLOW_ROUTE nonce="给定nonce" route="direct_reply"/>
|
||||
当然可以,我先直接回答你这个问题。
|
||||
|
||||
<SMARTFLOW_ROUTE nonce="给定nonce" route="quick_task"/>
|
||||
|
||||
<SMARTFLOW_ROUTE nonce="给定nonce" route="execute"/>
|
||||
好的,我来帮你看看今天的安排。
|
||||
|
||||
<SMARTFLOW_ROUTE nonce="给定nonce" route="execute" rough_build="true" refine="false" reorder="false" thinking="false"/>
|
||||
好的,我来帮你排课。
|
||||
|
||||
<SMARTFLOW_ROUTE nonce="给定nonce" route="execute" rough_build="true" refine="true" reorder="false" thinking="true"/>
|
||||
好的,我来帮你排课并按你的偏好做微调。
|
||||
|
||||
<SMARTFLOW_ROUTE nonce="给定nonce" route="deep_answer"/>
|
||||
|
||||
<SMARTFLOW_ROUTE nonce="给定nonce" route="plan"/>
|
||||
明白,我来帮你制定一个完整的学习计划。
|
||||
|
||||
禁止输出任何 JSON、markdown 代码块或额外解释。nonce 必须精确使用给定值。
|
||||
`
|
||||
|
||||
// BuildChatRoutingSystemPrompt 返回路由阶段的系统提示词。
|
||||
func BuildChatRoutingSystemPrompt() string {
|
||||
return strings.TrimSpace(chatRoutingSystemPrompt)
|
||||
}
|
||||
|
||||
// BuildChatRoutingMessages 组装路由阶段的 messages。
|
||||
func BuildChatRoutingMessages(ctx *agentmodel.ConversationContext, userInput string, state *agentmodel.CommonState, nonce string) []*schema.Message {
|
||||
return buildUnifiedStageMessages(
|
||||
ctx,
|
||||
StageMessagesConfig{
|
||||
SystemPrompt: BuildChatRoutingSystemPrompt(),
|
||||
Msg1Content: buildChatConversationMessage(ctx),
|
||||
Msg2Content: buildChatRoutingWorkspace(ctx),
|
||||
Msg3Suffix: BuildChatRoutingUserPrompt(userInput, nonce),
|
||||
Msg3Role: schema.User,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// BuildChatRoutingUserPrompt 构造路由阶段的用户提示词。
|
||||
func BuildChatRoutingUserPrompt(userInput string, nonce string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("nonce=%s\n", nonce))
|
||||
sb.WriteString(fmt.Sprintf("当前时间=%s\n", time.Now().In(time.Local).Format("2006-01-02 15:04")))
|
||||
sb.WriteString("\n请基于最近真实对话和本轮输入选择最合适的路由,并严格按系统约定输出控制码。\n")
|
||||
|
||||
trimmedInput := strings.TrimSpace(userInput)
|
||||
if trimmedInput != "" {
|
||||
sb.WriteString("\n用户本轮输入:\n")
|
||||
sb.WriteString(trimmedInput)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// --- 深度回答 prompt ---
|
||||
|
||||
const deepAnswerSystemPrompt = `
|
||||
你是 SmartMate 的深度分析助手。SmartMate 是时伴(SmartMate)的中文 AI 排程伙伴;即使问题与日程、任务无关,只要不需要工具,你也应当认真分析后给出详细、有价值的回答。
|
||||
|
||||
请遵守以下规则:
|
||||
1. 优先回答用户真实问题,不要把普通问答硬拐回排程、任务或计划制定。
|
||||
2. 充分利用上下文中已有的信息(历史对话、记忆、任务类约束、日程数据等),但不要无关硬套。
|
||||
3. 如果缺少关键信息,在回答中说明需要哪些额外信息。
|
||||
4. 直接输出你的回答,不要输出 JSON。
|
||||
`
|
||||
|
||||
// BuildDeepAnswerSystemPrompt 返回深度回答阶段的系统提示词。
|
||||
func BuildDeepAnswerSystemPrompt() string {
|
||||
return strings.TrimSpace(deepAnswerSystemPrompt)
|
||||
}
|
||||
|
||||
// BuildDeepAnswerMessages 组装深度回答阶段的 messages。
|
||||
func BuildDeepAnswerMessages(state *agentmodel.CommonState, ctx *agentmodel.ConversationContext, userInput string) []*schema.Message {
|
||||
return buildUnifiedStageMessages(
|
||||
ctx,
|
||||
StageMessagesConfig{
|
||||
SystemPrompt: BuildDeepAnswerSystemPrompt(),
|
||||
Msg1Content: buildChatConversationMessage(ctx),
|
||||
Msg2Content: buildDeepAnswerWorkspace(),
|
||||
Msg3Suffix: buildDeepAnswerUserPrompt(userInput),
|
||||
Msg3Role: schema.User,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func buildDeepAnswerUserPrompt(userInput string) string {
|
||||
trimmedInput := strings.TrimSpace(userInput)
|
||||
if trimmedInput != "" {
|
||||
return trimmedInput
|
||||
}
|
||||
return "请直接回答用户刚才的问题。"
|
||||
}
|
||||
33
backend/services/agent/prompt/chat_context.go
Normal file
33
backend/services/agent/prompt/chat_context.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
)
|
||||
|
||||
// buildChatConversationMessage 生成 chat / deep_answer 共用的真实对话视图。
|
||||
func buildChatConversationMessage(ctx *agentmodel.ConversationContext) string {
|
||||
return buildConversationHistoryMessage(ctx, "真实对话记录")
|
||||
}
|
||||
|
||||
// buildChatRoutingWorkspace 渲染 chat 路由节点的轻量补充区。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. chat 只保留与路由判断直接相关的最小流程标记;
|
||||
// 2. rough_build_done 仍需显式暴露,否则路由层会丢掉“不要重复粗排”的关键信号;
|
||||
// 3. 不再展示轮次、阶段锚点、ReAct 摘要等 execute 专属信息。
|
||||
func buildChatRoutingWorkspace(ctx *agentmodel.ConversationContext) string {
|
||||
lines := []string{"路由补充:"}
|
||||
if hasExecuteRoughBuildDone(ctx) {
|
||||
lines = append(lines, "- 已存在 rough_build_done;除非用户明确要求重新粗排,否则不要再次触发 rough_build。")
|
||||
} else {
|
||||
lines = append(lines, "- 暂无额外流程标记。")
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// buildDeepAnswerWorkspace 渲染 deep_answer 节点的轻量工作区。
|
||||
func buildDeepAnswerWorkspace() string {
|
||||
return "回答补充:请直接延续最近对话,聚焦回答用户本轮问题。"
|
||||
}
|
||||
62
backend/services/agent/prompt/compact_msg1.go
Normal file
62
backend/services/agent/prompt/compact_msg1.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const compactMsg1SystemPrompt = `你是一个对话压缩助手。你的任务是将以下多轮对话历史压缩为一段简洁的结构化摘要。
|
||||
|
||||
要求:
|
||||
1. 保留用户的核心诉求和意图(原文关键词不要丢失)
|
||||
2. 保留所有已确认的约束条件和规则
|
||||
3. 保留关键操作决策和结果(比如排程相关的调整结果)
|
||||
4. 保留用户偏好的重要信息
|
||||
5. 去除冗余和重复信息
|
||||
6. 按要点列出,每条一行
|
||||
|
||||
直接输出压缩后的摘要,不要输出其他内容。`
|
||||
|
||||
// CompactMsg1 将 msg1(历史对话)压缩为摘要。
|
||||
// existingSummary 不为空时表示已有旧摘要,需要合并压缩。
|
||||
func CompactMsg1(
|
||||
ctx context.Context,
|
||||
client *llmservice.Client,
|
||||
historyText string,
|
||||
existingSummary string,
|
||||
) (string, error) {
|
||||
var userContent string
|
||||
if existingSummary != "" {
|
||||
userContent = fmt.Sprintf(`已有压缩摘要:
|
||||
%s
|
||||
|
||||
新增的对话记录:
|
||||
%s
|
||||
|
||||
请将以上两部分合并为一份更紧凑的摘要。`, existingSummary, historyText)
|
||||
} else {
|
||||
userContent = fmt.Sprintf(`对话历史:
|
||||
%s
|
||||
|
||||
请压缩以上对话历史。`, historyText)
|
||||
}
|
||||
|
||||
messages := []*schema.Message{
|
||||
schema.SystemMessage(compactMsg1SystemPrompt),
|
||||
schema.UserMessage(userContent),
|
||||
}
|
||||
|
||||
result, err := client.GenerateText(ctx, messages, llmservice.GenerateOptions{
|
||||
MaxTokens: 4000,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("compact msg1 LLM call failed: %w", err)
|
||||
}
|
||||
if result == nil || result.Text == "" {
|
||||
return "", fmt.Errorf("compact msg1 LLM returned empty result")
|
||||
}
|
||||
return result.Text, nil
|
||||
}
|
||||
49
backend/services/agent/prompt/compact_msg2.go
Normal file
49
backend/services/agent/prompt/compact_msg2.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const compactMsg2SystemPrompt = `你是一个执行记录压缩助手。你的任务是将以下 ReAct 执行循环记录压缩为简洁摘要。
|
||||
|
||||
要求:
|
||||
1. 保留每个工具调用的关键返回值(尤其是包含排程数据的JSON)
|
||||
2. 保留执行路径(哪些操作成功了,哪些失败了)
|
||||
3. 保留当前执行进度(正在做什么,下一步要做什么)
|
||||
4. 去除重复的工具调用结果
|
||||
5. 按时间顺序组织,每条一行
|
||||
|
||||
直接输出压缩后的摘要,不要输出其他内容。`
|
||||
|
||||
// CompactMsg2 将 msg2(ReAct Loop 记录)的早期部分压缩为摘要。
|
||||
// recentText 是保留的近期记录原文,不参与压缩。
|
||||
func CompactMsg2(
|
||||
ctx context.Context,
|
||||
client *llmservice.Client,
|
||||
earlyLoopText string,
|
||||
) (string, error) {
|
||||
userContent := fmt.Sprintf(`早期的 ReAct 执行记录:
|
||||
%s
|
||||
|
||||
请压缩以上执行记录,保留关键信息。`, earlyLoopText)
|
||||
|
||||
messages := []*schema.Message{
|
||||
schema.SystemMessage(compactMsg2SystemPrompt),
|
||||
schema.UserMessage(userContent),
|
||||
}
|
||||
|
||||
result, err := client.GenerateText(ctx, messages, llmservice.GenerateOptions{
|
||||
MaxTokens: 4000,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("compact msg2 LLM call failed: %w", err)
|
||||
}
|
||||
if result == nil || result.Text == "" {
|
||||
return "", fmt.Errorf("compact msg2 LLM returned empty result")
|
||||
}
|
||||
return result.Text, nil
|
||||
}
|
||||
37
backend/services/agent/prompt/conversation_view.go
Normal file
37
backend/services/agent/prompt/conversation_view.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
)
|
||||
|
||||
// buildConversationHistoryMessage 将“真实对话流”渲染成节点可直接复用的 msg1。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责把 user + assistant speak 组织成稳定文本;
|
||||
// 2. 不拼接 tool_call / tool observation,这些不属于“真实对话”;
|
||||
// 3. 不做长度裁剪,长度预算交给统一压缩层处理。
|
||||
func buildConversationHistoryMessage(ctx *agentmodel.ConversationContext, title string) string {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
title = "真实对话记录"
|
||||
}
|
||||
|
||||
lines := []string{title + ":"}
|
||||
if ctx == nil {
|
||||
lines = append(lines, "暂无。")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
turns := CollectConversationTurns(ctx.HistorySnapshot())
|
||||
if len(turns) == 0 {
|
||||
lines = append(lines, "暂无。")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
for _, turn := range turns {
|
||||
lines = append(lines, turn.Role+": \""+turn.Content+"\"")
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
78
backend/services/agent/prompt/deliver.go
Normal file
78
backend/services/agent/prompt/deliver.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const deliverSystemPrompt = `
|
||||
你是 SmartMate 的交付器。你的职责是基于原始计划和执行历史,生成一份简洁、诚实的任务完成总结。
|
||||
请遵守以下规则:
|
||||
1. 只基于已有历史和计划状态生成总结,不要编造未执行的操作。
|
||||
2. 如果所有步骤都已完成,请自然概括每一步的主要成果。
|
||||
3. 如果流程因轮次耗尽或主动终止而提前结束,请如实说明当前进度与未完成部分。
|
||||
4. 使用自然、友好的语气,不要机械罗列工具过程。
|
||||
5. 如果用户后续还需要继续操作,可以给出一句简短建议。
|
||||
6. 只输出总结文本,不要输出 JSON,也不要输出 markdown 标题。
|
||||
你会看到:
|
||||
- 原始计划步骤及完成进度
|
||||
- 最近真实对话
|
||||
- 当前流程的收口状态`
|
||||
|
||||
// BuildDeliverSystemPrompt 返回交付阶段系统提示词。
|
||||
func BuildDeliverSystemPrompt() string {
|
||||
return strings.TrimSpace(deliverSystemPrompt)
|
||||
}
|
||||
|
||||
// BuildDeliverMessages 组装交付阶段 messages。
|
||||
func BuildDeliverMessages(state *agentmodel.CommonState, ctx *agentmodel.ConversationContext) []*schema.Message {
|
||||
roughBuildPrefix := buildDeliverRoughBuildPrefix(ctx, state)
|
||||
return buildUnifiedStageMessages(
|
||||
ctx,
|
||||
StageMessagesConfig{
|
||||
SystemPrompt: BuildDeliverSystemPrompt(),
|
||||
Msg1Content: buildDeliverConversationMessage(ctx),
|
||||
Msg2Content: buildDeliverWorkspace(state, ctx),
|
||||
Msg3Prefix: roughBuildPrefix,
|
||||
Msg3Suffix: BuildDeliverUserPrompt(state, ctx),
|
||||
Msg3Role: schema.User,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// BuildDeliverUserPrompt 构造交付阶段的用户提示词。
|
||||
func BuildDeliverUserPrompt(state *agentmodel.CommonState, ctx *agentmodel.ConversationContext) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("请基于最近对话和交付工作区,生成一段自然、诚实的完成总结。\n")
|
||||
|
||||
if state == nil || !state.HasPlan() {
|
||||
if hasExecuteRoughBuildDone(ctx) {
|
||||
sb.WriteString("当前没有正式计划,但本轮已经完成粗排,请结合粗排补充和任务类详情总结粗排结果,不要把它说成正式完结。\n")
|
||||
} else {
|
||||
sb.WriteString("当前没有正式计划,请只概括本次互动,不要编造成果。\n")
|
||||
}
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
completed := countCompletedPlanSteps(state)
|
||||
total := len(state.PlanSteps)
|
||||
|
||||
if state.IsExhaustedTerminal() {
|
||||
sb.WriteString(fmt.Sprintf("注意:任务因轮次耗尽提前结束,当前已完成 %d/%d 步。\n", completed, total))
|
||||
sb.WriteString("请如实说明已完成与未完成的部分,并给出一句继续建议。\n")
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
if state.IsAborted() {
|
||||
sb.WriteString(fmt.Sprintf("注意:流程已被主动终止,当前已完成 %d/%d 步。\n", completed, total))
|
||||
sb.WriteString("请如实说明停在何处,以及用户若想继续应如何衔接。\n")
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
sb.WriteString("若计划已正常完成,请概括整体成果;若仍有未完成步骤,也必须如实说明。\n")
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
145
backend/services/agent/prompt/deliver_context.go
Normal file
145
backend/services/agent/prompt/deliver_context.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
)
|
||||
|
||||
// buildDeliverConversationMessage 生成 deliver 节点看到的轻量历史提示。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里不再承载完整历史,也不再把旧轮次对话重新灌回 deliver;
|
||||
// 2. 真正可供收口的本轮 execute 窗口放到 msg2,由工作区统一呈现;
|
||||
// 3. 这里只给模型一个明确提示:历史已经折叠,请不要主动回顾旧轮次。
|
||||
func buildDeliverConversationMessage(ctx *agentmodel.ConversationContext) string {
|
||||
return "历史视图:已折叠到交付工作区的本轮 execute 窗口,请仅依据 msg2 收口,不要回顾旧轮次。"
|
||||
}
|
||||
|
||||
// buildDeliverRoughBuildPrefix 构造 deliver 在“粗排已完成”场景下的专属前缀。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只负责把粗排相关的任务类信息补进 msg3 前缀,不改写交付总结本身;
|
||||
// 2. 只有在上下文里明确存在 rough_build_done 时才注入,避免普通交付场景被额外信息污染;
|
||||
// 3. 这段前缀用于补齐第一次粗排没有正式计划时的任务类详情,优先让 deliver 看到 task_class_ids 和任务类约束。
|
||||
func buildDeliverRoughBuildPrefix(ctx *agentmodel.ConversationContext, state *agentmodel.CommonState) string {
|
||||
if !hasExecuteRoughBuildDone(ctx) {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := []string{
|
||||
"粗排补充信息:",
|
||||
"- 本轮已经完成粗排,相关任务类已进入 suggested/existing,不要把它们说成正式计划。",
|
||||
}
|
||||
|
||||
if taskClassIDs := renderPlanTaskClassIDs(state); taskClassIDs != "" {
|
||||
lines = append(lines, "- "+taskClassIDs)
|
||||
}
|
||||
if taskClassMeta := renderPlanTaskClassMeta(state); taskClassMeta != "" {
|
||||
lines = append(lines, "任务类详情:")
|
||||
lines = append(lines, taskClassMeta)
|
||||
}
|
||||
|
||||
if state == nil || !state.HasPlan() {
|
||||
lines = append(lines, "- 当前没有正式计划,请把这批任务类的粗排结果作为总结重点。")
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// buildDeliverWorkspace 渲染 deliver 节点自己的结果态工作区。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 先保留 deliver 原本依赖的结果态信息:terminal outcome、计划进度、步骤简表;
|
||||
// 2. 再把基于 execute_loop_closed 切出来的“本轮 execute 窗口”拼到 msg2,作为唯一的本轮事实视图;
|
||||
// 3. 没有正式计划时也保留 execute 窗口,保证 deliver 仍能基于当前轮活跃上下文诚实收口。
|
||||
func buildDeliverWorkspace(state *agentmodel.CommonState, ctx *agentmodel.ConversationContext) string {
|
||||
lines := []string{"交付工作区:"}
|
||||
if state == nil {
|
||||
lines = append(lines, "- 当前缺少流程状态,请仅基于可见结果态与本轮 execute 窗口诚实收口。")
|
||||
lines = append(lines, "", buildDeliverExecuteWindow(ctx))
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
lines = append(lines, renderDeliverTerminalSummary(state))
|
||||
if !state.HasPlan() {
|
||||
lines = append(lines, "- 当前没有正式计划,请只概括本次互动。")
|
||||
lines = append(lines, "", buildDeliverExecuteWindow(ctx))
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
total := len(state.PlanSteps)
|
||||
completed := countCompletedPlanSteps(state)
|
||||
lines = append(lines, fmt.Sprintf("- 计划进度:已完成 %d/%d 步。", completed, total))
|
||||
lines = append(lines, "计划步骤:")
|
||||
lines = append(lines, renderDeliverStepOutline(state, completed))
|
||||
lines = append(lines, "", buildDeliverExecuteWindow(ctx))
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// renderDeliverTerminalSummary 返回 deliver 节点需要知道的收口状态。
|
||||
func renderDeliverTerminalSummary(state *agentmodel.CommonState) string {
|
||||
if state == nil || !state.HasTerminalOutcome() || state.TerminalOutcome == nil {
|
||||
return "- 当前没有正式终止结果,请按最近对话和计划进度自然总结。"
|
||||
}
|
||||
|
||||
outcome := state.TerminalOutcome
|
||||
line := fmt.Sprintf("- 收口状态:%s", outcome.Status)
|
||||
if stage := strings.TrimSpace(outcome.Stage); stage != "" {
|
||||
line += fmt.Sprintf(";阶段:%s", stage)
|
||||
}
|
||||
if msg := strings.TrimSpace(outcome.UserMessage); msg != "" {
|
||||
line += fmt.Sprintf(";用户提示:%s", msg)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// renderDeliverStepOutline 生成 deliver 节点使用的步骤简表。
|
||||
func renderDeliverStepOutline(state *agentmodel.CommonState, completed int) string {
|
||||
if state == nil || len(state.PlanSteps) == 0 {
|
||||
return "- 暂无。"
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(state.PlanSteps))
|
||||
for i, step := range state.PlanSteps {
|
||||
status := "未完成"
|
||||
if i < completed {
|
||||
status = "已完成"
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(step.Content)
|
||||
if content == "" {
|
||||
content = "(步骤正文为空)"
|
||||
}
|
||||
line := fmt.Sprintf("%d. [%s] %s", i+1, status, content)
|
||||
if doneWhen := strings.TrimSpace(step.DoneWhen); doneWhen != "" {
|
||||
line += fmt.Sprintf(" | 完成判定:%s", doneWhen)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// countCompletedPlanSteps 统计当前已经完成的计划步骤数。
|
||||
func countCompletedPlanSteps(state *agentmodel.CommonState) int {
|
||||
if state == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
total := len(state.PlanSteps)
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
if state.CurrentStep <= 0 {
|
||||
if state.IsCompleted() {
|
||||
return total
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if state.CurrentStep >= total {
|
||||
return total
|
||||
}
|
||||
return state.CurrentStep
|
||||
}
|
||||
103
backend/services/agent/prompt/deliver_window.go
Normal file
103
backend/services/agent/prompt/deliver_window.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const deliverHistoryKindExecuteLoopClosed = "execute_loop_closed"
|
||||
|
||||
// sliceHistoryAfterLastExecuteLoopClosed 基于最后一个 execute_loop_closed 标记切出当前活跃窗口。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先读取完整 history 快照,避免直接在 ConversationContext 原地切片,减少后续调用方误改底层数组的风险;
|
||||
// 2. 从后往前找最后一个 execute_loop_closed,确保拿到的是“最近一次已正常收口”的边界;
|
||||
// 3. 命中边界后只返回边界之后的消息,这样 deliver 看到的就是当前活跃轮次;
|
||||
// 4. 若完全没有边界,说明会话尚未形成稳定闭环,此时退回全量 history,避免误丢当前活跃上下文。
|
||||
func sliceHistoryAfterLastExecuteLoopClosed(ctx *agentmodel.ConversationContext) []*schema.Message {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
history := ctx.HistorySnapshot()
|
||||
if len(history) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cut := -1
|
||||
for i := len(history) - 1; i >= 0; i-- {
|
||||
if isDeliverExecuteLoopClosedMarker(history[i]) {
|
||||
cut = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if cut < 0 {
|
||||
return history
|
||||
}
|
||||
if cut+1 >= len(history) {
|
||||
return nil
|
||||
}
|
||||
return history[cut+1:]
|
||||
}
|
||||
|
||||
// isDeliverExecuteLoopClosedMarker 判断一条历史消息是否为 execute loop 正常收口边界。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只识别 prompt 层真正关心的 execute_loop_closed 标记;
|
||||
// 2. 不负责推断其他中断/恢复语义,避免把 confirm/ask_user 等同一轮过程误判成新边界;
|
||||
// 3. 若消息结构不完整,则统一按“非边界”处理,保证切窗策略保守可回退。
|
||||
func isDeliverExecuteLoopClosedMarker(msg *schema.Message) bool {
|
||||
if msg == nil || msg.Extra == nil {
|
||||
return false
|
||||
}
|
||||
kind, ok := msg.Extra[executeHistoryKindKey].(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(kind) == deliverHistoryKindExecuteLoopClosed
|
||||
}
|
||||
|
||||
// buildDeliverExecuteWindow 基于当前活跃 history 窗口渲染 deliver 节点要看的执行事实视图。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先按 execute_loop_closed 切掉旧轮次,只保留当前仍活跃的执行窗口;
|
||||
// 2. 再分别抽取“本轮真实对话流”和“本轮 ReAct 工具事实链”,避免 deliver 回看旧 deliver 总结;
|
||||
// 3. 若本轮还没有工具调用,也要明确告诉模型“当前无工具事实”,避免它擅自脑补;
|
||||
// 4. 整段文本只服务 deliver.msg2,不改变四段式骨架,也不回写任何状态。
|
||||
func buildDeliverExecuteWindow(ctx *agentmodel.ConversationContext) string {
|
||||
lines := []string{"本轮 execute 窗口:"}
|
||||
|
||||
historyWindow := sliceHistoryAfterLastExecuteLoopClosed(ctx)
|
||||
if len(historyWindow) == 0 {
|
||||
lines = append(lines, "- 当前没有可用的本轮执行窗口,请仅依据结果态工作区诚实收口。")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
turns := collectExecuteConversationTurns(historyWindow)
|
||||
if len(turns) == 0 {
|
||||
lines = append(lines, "- 本轮对话流:暂无。")
|
||||
} else {
|
||||
lines = append(lines, "本轮对话流:")
|
||||
for _, turn := range turns {
|
||||
lines = append(lines, fmt.Sprintf("- %s: %q", turn.Role, turn.Content))
|
||||
}
|
||||
}
|
||||
|
||||
loops := collectExecuteLoopRecords(historyWindow)
|
||||
if len(loops) == 0 {
|
||||
lines = append(lines, "- 本轮 ReAct 记录:暂无工具调用。")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
lines = append(lines, "本轮 ReAct 记录:")
|
||||
for i, loop := range loops {
|
||||
lines = append(lines, fmt.Sprintf("%d. thought/reason:%s", i+1, loop.Thought))
|
||||
lines = append(lines, fmt.Sprintf(" tool_call:%s", renderExecuteToolCallText(loop.ToolName, loop.ToolArgs)))
|
||||
lines = append(lines, fmt.Sprintf(" observation:%s", loop.Observation))
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
137
backend/services/agent/prompt/execute.go
Normal file
137
backend/services/agent/prompt/execute.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// BuildExecuteSystemPrompt 返回执行阶段系统提示词(有 plan 模式)。
|
||||
func BuildExecuteSystemPrompt() string {
|
||||
return buildExecutePromptWithFormatGuard(executeSystemPromptBaseWithPlan)
|
||||
}
|
||||
|
||||
// BuildExecuteReActSystemPrompt 返回执行阶段系统提示词(自由执行模式)。
|
||||
func BuildExecuteReActSystemPrompt() string {
|
||||
return buildExecutePromptWithFormatGuard(executeSystemPromptBaseReAct)
|
||||
}
|
||||
|
||||
// BuildExecuteMessages 组装执行阶段消息。
|
||||
func BuildExecuteMessages(state *agentmodel.CommonState, ctx *agentmodel.ConversationContext) []*schema.Message {
|
||||
if state != nil && state.HasPlan() {
|
||||
return buildExecuteStageMessages(
|
||||
BuildExecuteSystemPrompt(),
|
||||
state,
|
||||
ctx,
|
||||
buildExecuteStrictJSONUserPromptWithPlan(state),
|
||||
)
|
||||
}
|
||||
|
||||
return buildExecuteStageMessages(
|
||||
BuildExecuteReActSystemPrompt(),
|
||||
state,
|
||||
ctx,
|
||||
buildExecuteStrictJSONUserPrompt(),
|
||||
)
|
||||
}
|
||||
|
||||
// buildExecuteStrictJSONUserPromptWithPlan 在通用 JSON 约束上补充"当前计划步骤"强约束。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把"当前是第几步、当前步骤内容、done_when 判定"明确写进用户指令;
|
||||
// 2. 不负责替代系统提示词中的工具规则和安全边界;
|
||||
// 3. 当 state 无法提供有效当前步骤时,仅追加兜底提示,不在此处推进流程状态。
|
||||
func buildExecuteStrictJSONUserPromptWithPlan(state *agentmodel.CommonState) string {
|
||||
base := buildExecuteStrictJSONUserPrompt()
|
||||
if state == nil || !state.HasPlan() {
|
||||
return base
|
||||
}
|
||||
|
||||
current, total := state.PlanProgress()
|
||||
step, ok := state.CurrentPlanStep()
|
||||
if !ok {
|
||||
return strings.TrimSpace(base + `
|
||||
|
||||
计划步骤强约束:
|
||||
- 当前没有可执行的计划步骤,请先基于已有事实检查是否已完成全部计划。
|
||||
- 若全部计划已完成:输出 action=done,并在 goal_check 总结完成证据。
|
||||
- goal_check 字段类型必须为 string,不要输出对象或数组。
|
||||
- 若未完成但缺少关键信息:输出 action=ask_user。`)
|
||||
}
|
||||
|
||||
stepContent := strings.TrimSpace(step.Content)
|
||||
if stepContent == "" {
|
||||
stepContent = "(当前步骤内容为空,以 done_when 为准)"
|
||||
}
|
||||
doneWhen := strings.TrimSpace(step.DoneWhen)
|
||||
if doneWhen == "" {
|
||||
doneWhen = "(未提供 done_when,需基于当前步骤目标给出可验证完成证据)"
|
||||
}
|
||||
|
||||
return strings.TrimSpace(fmt.Sprintf(`%s
|
||||
|
||||
计划步骤强约束:
|
||||
- 你当前只允许推进第 %d/%d 步。
|
||||
- 当前步骤内容:%s
|
||||
- 当前步骤完成判定(done_when):%s
|
||||
- 未满足 done_when 时:只能输出 continue / confirm / ask_user,禁止输出 next_plan。
|
||||
- 满足 done_when 时:优先输出 action=next_plan,并在 goal_check 逐条对照 done_when 给出证据。
|
||||
- goal_check 字段类型固定为 string(示例:"已满足 done_when:...;证据:..."),禁止输出 {"done_when":"...","evidence":"..."}。
|
||||
- 禁止跳步:不要提前执行后续步骤。`,
|
||||
base, current, total, stepContent, doneWhen))
|
||||
}
|
||||
|
||||
// buildExecutePromptWithFormatGuard 统一补一层更硬的 JSON 输出约束。
|
||||
func buildExecutePromptWithFormatGuard(base string) string {
|
||||
base = strings.TrimSpace(base)
|
||||
guard := strings.TrimSpace(`
|
||||
输出协议硬约束:
|
||||
1. 只输出当前 action 真正需要的字段;不要输出空字符串、空对象、空数组或 null 占位。
|
||||
2. tool_call 只能是 {"name":"工具名","arguments":{...}};不能写 parameters,也不能一次输出多个 tool_call。
|
||||
3. action=ask_user / confirm 时,标签后必须有自然语言正文;action=continue 可为空,但只允许配合读工具或纯思考,不能携带任何写工具。
|
||||
4. action=done 时不要携带 tool_call;action=next_plan / done 时,goal_check 必须是字符串。
|
||||
5. 只有 action=abort 时才允许输出 abort 字段。
|
||||
6. <SMARTFLOW_DECISION> 标签内只放 JSON,不要放自然语言。
|
||||
7. 不要在 <SMARTFLOW_DECISION> 标签前输出任何前言、寒暄、解释或铺垫;给用户看的正文只能放在 </SMARTFLOW_DECISION> 之后。
|
||||
8. 任何动作都不得擅自超出用户当前明确意图;用户没让你做的下一步,不要自作主张推进。`)
|
||||
if base == "" {
|
||||
return guard
|
||||
}
|
||||
return base + "\n\n" + guard
|
||||
}
|
||||
|
||||
// buildExecuteStrictJSONUserPrompt 统一构造 execute 阶段面向模型的最终用户指令。
|
||||
func buildExecuteStrictJSONUserPrompt() string {
|
||||
return strings.TrimSpace(`
|
||||
请继续当前任务的执行阶段,严格按 SMARTFLOW_DECISION 标签格式输出。
|
||||
输出格式:先输出 <SMARTFLOW_DECISION>{JSON 决策}</SMARTFLOW_DECISION>,然后换行输出给用户看的正文。
|
||||
|
||||
执行提醒:
|
||||
- JSON 中不要包含 speak 字段;给用户看的话放在 </SMARTFLOW_DECISION> 标签之后
|
||||
- 不要在 <SMARTFLOW_DECISION> 标签之前输出任何文字;哪怕只有一句“我先看下”也不行
|
||||
- 任何写工具都一律走 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 静默闭环;除非真缺用户关键信息,否则不要把主要篇幅花在解释工具内部约束上
|
||||
`)
|
||||
}
|
||||
788
backend/services/agent/prompt/execute_context.go
Normal file
788
backend/services/agent/prompt/execute_context.go
Normal file
@@ -0,0 +1,788 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
// executeHistoryKindKey 用于在 history 里区分普通用户消息与后端注入的纠错提示。
|
||||
// 这里负责“识别并过滤”,不负责写入该标记。
|
||||
executeHistoryKindKey = "newagent_history_kind"
|
||||
executeHistoryKindCorrectionUser = "llm_correction_prompt"
|
||||
)
|
||||
|
||||
type executeToolSchemaDoc struct {
|
||||
Name string `json:"name"`
|
||||
Parameters map[string]any `json:"parameters"`
|
||||
}
|
||||
|
||||
type executeLoopRecord struct {
|
||||
Thought string
|
||||
ToolName string
|
||||
ToolArgs string
|
||||
Observation string
|
||||
}
|
||||
|
||||
type conversationTurn struct {
|
||||
Role string
|
||||
Content string
|
||||
}
|
||||
|
||||
type executeLatestToolRecord struct {
|
||||
ToolName string
|
||||
Observation string
|
||||
}
|
||||
|
||||
// buildExecuteStageMessages 组装 execute 阶段的四段式消息。
|
||||
//
|
||||
// 1. msg0:系统提示 + 动态规则包 + 工具简表。
|
||||
// 2. msg1:真实对话流,只保留 user 和 assistant speak。
|
||||
// 3. msg2:当前 ReAct tool loop 记录。
|
||||
// 4. msg3:执行状态、阶段约束、记忆和本轮指令。
|
||||
func buildExecuteStageMessages(
|
||||
stageSystemPrompt string,
|
||||
state *agentmodel.CommonState,
|
||||
ctx *agentmodel.ConversationContext,
|
||||
runtimeUserPrompt string,
|
||||
) []*schema.Message {
|
||||
msg0 := buildExecuteMessage0(stageSystemPrompt, state, ctx)
|
||||
msg1 := buildExecuteMessage1V3(ctx)
|
||||
msg2 := buildExecuteMessage2V3(ctx)
|
||||
msg3 := buildExecuteMessage3(state, ctx, runtimeUserPrompt)
|
||||
|
||||
return []*schema.Message{
|
||||
schema.SystemMessage(msg0),
|
||||
{Role: schema.Assistant, Content: msg1},
|
||||
{Role: schema.Assistant, Content: msg2},
|
||||
schema.SystemMessage(msg3),
|
||||
}
|
||||
}
|
||||
|
||||
// buildExecuteMessage0 生成 execute 阶段的固定规则消息。
|
||||
//
|
||||
// 1. 先拼基础 system prompt,保证身份和输出协议稳定。
|
||||
// 2. 再按当前 domain / packs 注入动态规则包,让模型先读到边界。
|
||||
// 3. 最后再附工具简表,避免模型只看到工具不看到纪律。
|
||||
func buildExecuteMessage0(stageSystemPrompt string, state *agentmodel.CommonState, ctx *agentmodel.ConversationContext) string {
|
||||
base := strings.TrimSpace(mergeSystemPrompts(ctx, stageSystemPrompt))
|
||||
if base == "" {
|
||||
base = "你是 SmartMate 执行器,请继续当前执行阶段。"
|
||||
}
|
||||
|
||||
rulePackSection, _ := renderExecuteRulePackSection(state, ctx)
|
||||
if rulePackSection != "" {
|
||||
base += "\n\n" + rulePackSection
|
||||
}
|
||||
|
||||
toolCatalog := renderExecuteToolCatalogCompact(ctx, state)
|
||||
if toolCatalog != "" {
|
||||
base += "\n\n" + toolCatalog
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// buildExecuteMessage1V3 只渲染真实对话流,不混入 tool observation。
|
||||
func buildExecuteMessage1V3(ctx *agentmodel.ConversationContext) string {
|
||||
lines := []string{"历史上下文:"}
|
||||
if ctx == nil {
|
||||
lines = append(lines,
|
||||
"- 对话历史:暂无。",
|
||||
"- 阶段锚点:按当前工具事实推进执行。",
|
||||
)
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
turns := collectExecuteConversationTurns(ctx.HistorySnapshot())
|
||||
if len(turns) == 0 {
|
||||
lines = append(lines, "- 对话历史:暂无。")
|
||||
} else {
|
||||
turnLines := make([]string, 0, len(turns)+1)
|
||||
turnLines = append(turnLines, "对话历史:")
|
||||
for _, turn := range turns {
|
||||
turnLines = append(turnLines, turn.Role+": \""+turn.Content+"\"")
|
||||
}
|
||||
lines = append(lines, strings.Join(turnLines, "\n"))
|
||||
}
|
||||
|
||||
if hasExecuteRoughBuildDone(ctx) {
|
||||
lines = append(lines, "- 阶段锚点:粗排已完成,本轮仅做微调,不重新 place。")
|
||||
} else {
|
||||
lines = append(lines, "- 阶段锚点:按当前工具事实推进,不做无依据操作。")
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// buildExecuteMessage2V3 只承载当轮 ReAct loop。
|
||||
//
|
||||
// 1. 每条记录固定展示 thought / tool_call / observation,方便模型做局部闭环。
|
||||
// 2. 如果当前还没有任何 tool loop,明确给“新一轮”占位,避免模型误判缺上下文。
|
||||
func buildExecuteMessage2V3(ctx *agentmodel.ConversationContext) string {
|
||||
lines := []string{"当轮 ReAct Loop 记录:"}
|
||||
if ctx == nil {
|
||||
lines = append(lines, "- 暂无可用 ReAct 记录。")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
loops := collectExecuteLoopRecords(ctx.HistorySnapshot())
|
||||
if len(loops) == 0 {
|
||||
lines = append(lines, "- 已清空(新一轮 loop 准备中)。")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
for i, loop := range loops {
|
||||
lines = append(lines, fmt.Sprintf("%d) thought/reason:%s", i+1, loop.Thought))
|
||||
lines = append(lines, fmt.Sprintf(" tool_call:%s", renderExecuteToolCallText(loop.ToolName, loop.ToolArgs)))
|
||||
lines = append(lines, fmt.Sprintf(" observation:%s", loop.Observation))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// buildExecuteMessage3 汇总当前执行状态和本轮指令。
|
||||
//
|
||||
// 1. 这里只放“当前轮真正会影响决策”的状态,避免 msg3 继续膨胀。
|
||||
// 2. 读工具最近结果只给最新一条摘要,避免旧 observation 重复占上下文。
|
||||
// 3. 最后一行固定落到“本轮指令”,保证模型收尾时注意力还在执行目标上。
|
||||
func buildExecuteMessage3(state *agentmodel.CommonState, ctx *agentmodel.ConversationContext, runtimeUserPrompt string) string {
|
||||
lines := []string{"当前执行状态:"}
|
||||
roughBuildDone := hasExecuteRoughBuildDone(ctx)
|
||||
|
||||
roundUsed, maxRounds := 0, agentmodel.DefaultMaxRounds
|
||||
modeText := "自由执行(无预定义步骤)"
|
||||
activeDomain := ""
|
||||
activePacks := []string{}
|
||||
if state != nil {
|
||||
roundUsed = state.RoundUsed
|
||||
if state.MaxRounds > 0 {
|
||||
maxRounds = state.MaxRounds
|
||||
}
|
||||
if state.HasPlan() {
|
||||
modeText = "计划执行(有预定义步骤)"
|
||||
}
|
||||
activeDomain = strings.TrimSpace(state.ActiveToolDomain)
|
||||
activePacks = readExecuteActiveToolPacks(state)
|
||||
}
|
||||
|
||||
lines = append(lines,
|
||||
fmt.Sprintf("- 当前轮次:%d/%d", roundUsed, maxRounds),
|
||||
"- 当前模式:"+modeText,
|
||||
)
|
||||
|
||||
if activeDomain == "" {
|
||||
lines = append(lines, "- 动态工具区:当前仅激活 context 管理工具。")
|
||||
} else if len(activePacks) == 0 {
|
||||
lines = append(lines, fmt.Sprintf("- 动态工具区:domain=%s,未显式激活 packs。", activeDomain))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("- 动态工具区:domain=%s,packs=[%s]。", activeDomain, strings.Join(activePacks, ",")))
|
||||
}
|
||||
|
||||
if state != nil && state.HasPlan() {
|
||||
current, total := state.PlanProgress()
|
||||
lines = append(lines, "计划步骤锚点(强约束):")
|
||||
if step, ok := state.CurrentPlanStep(); ok {
|
||||
stepContent := strings.TrimSpace(step.Content)
|
||||
if stepContent == "" {
|
||||
stepContent = "(当前步骤内容为空)"
|
||||
}
|
||||
doneWhen := strings.TrimSpace(step.DoneWhen)
|
||||
if doneWhen == "" {
|
||||
doneWhen = "(未提供 done_when,需基于步骤目标给出可验证完成证据)"
|
||||
}
|
||||
lines = append(lines,
|
||||
fmt.Sprintf("- 当前步骤:第 %d/%d 步", current, total),
|
||||
"- 当前步骤内容:"+stepContent,
|
||||
"- 当前步骤完成判定(done_when):"+doneWhen,
|
||||
"- 动作纪律1:未满足 done_when 时,只能 continue / confirm / ask_user,禁止 next_plan。",
|
||||
"- 动作纪律2:满足 done_when 时,优先 next_plan,并在 goal_check 对照 done_when 给证据。",
|
||||
"- 动作纪律3:禁止跳到后续步骤执行。",
|
||||
)
|
||||
} else {
|
||||
lines = append(lines,
|
||||
"- 当前计划步骤不可读;请先判断是否已完成全部计划。",
|
||||
"- 若已完成全部计划,输出 done 并给出 goal_check 证据。",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if latestAnalyze := renderExecuteLatestAnalyzeSummary(ctx); latestAnalyze != "" {
|
||||
lines = append(lines, "- 最近一次诊断:"+latestAnalyze)
|
||||
}
|
||||
if latestMutation := renderExecuteLatestMutationSummary(ctx); latestMutation != "" {
|
||||
lines = append(lines, "- 最近一次写操作:"+latestMutation)
|
||||
}
|
||||
if taskClassText := renderExecuteTaskClassIDs(state); taskClassText != "" {
|
||||
lines = append(lines, "- 目标任务类:"+taskClassText)
|
||||
}
|
||||
|
||||
lines = append(lines,
|
||||
"- 啥时候结束Loop:你可以根据工具调用记录自行判断。",
|
||||
"- 非目标:不重新粗排、不修改无关任务类。",
|
||||
)
|
||||
if roughBuildDone {
|
||||
lines = append(lines, "- 阶段约束:粗排已完成,本轮只微调 suggested;existing 仅作已安排事实参考,不作为可移动目标。")
|
||||
}
|
||||
lines = append(lines, "- 参数纪律:工具参数必须严格使用 schema 字段;若返回“参数非法”,需先改参再继续。")
|
||||
|
||||
if state != nil {
|
||||
if state.AllowReorder {
|
||||
lines = append(lines, "- 顺序策略:用户已明确允许打乱顺序,但当前主链不再提供顺序重排工具,请优先使用 move/swap 做局部调整。")
|
||||
} else {
|
||||
lines = append(lines, "- 顺序策略:默认保持 suggested 相对顺序,仅做局部 move/swap 调整。")
|
||||
}
|
||||
}
|
||||
|
||||
if upsertRuntime := renderTaskClassUpsertRuntime(state); upsertRuntime != "" {
|
||||
lines = append(lines, "任务类写入运行态:")
|
||||
lines = append(lines, upsertRuntime)
|
||||
}
|
||||
|
||||
if memoryText := renderExecuteMemoryContext(ctx); memoryText != "" {
|
||||
lines = append(lines, "相关记忆(仅在确有帮助时参考,不要机械复述):")
|
||||
lines = append(lines, memoryText)
|
||||
}
|
||||
|
||||
latestAnalyze := renderExecuteLatestAnalyzeSummary(ctx)
|
||||
latestMutation := renderExecuteLatestMutationSummary(ctx)
|
||||
if nextStep := renderExecuteNextStepHintV2(state, latestAnalyze, latestMutation, roughBuildDone); nextStep != "" {
|
||||
lines = append(lines, "下一步提示:")
|
||||
lines = append(lines, "- "+nextStep)
|
||||
}
|
||||
|
||||
instruction := strings.TrimSpace(runtimeUserPrompt)
|
||||
if instruction == "" {
|
||||
instruction = "请继续当前任务执行阶段,严格按 SMARTFLOW_DECISION 标签格式输出。"
|
||||
} else {
|
||||
instruction = firstExecuteLine(instruction)
|
||||
}
|
||||
lines = append(lines, "本轮指令:"+instruction)
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// renderExecuteToolCatalogCompact 将当前 tool schemas 渲染为紧凑简表。
|
||||
//
|
||||
// 1. 这里只给模型最低必要的参数和返回值感知,不重复塞完整 schema JSON。
|
||||
// 2. 对复杂工具额外给一条调用示例,降低“参数字段写错”的概率。
|
||||
// 3. 这里只展示当前真实可用工具,避免历史残留能力继续污染工具面。
|
||||
func renderExecuteToolCatalogCompact(ctx *agentmodel.ConversationContext, state *agentmodel.CommonState) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
schemas := ctx.ToolSchemasSnapshot()
|
||||
if len(schemas) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := []string{"可用工具(简表):"}
|
||||
index := 0
|
||||
for _, schemaItem := range schemas {
|
||||
name := strings.TrimSpace(schemaItem.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
index++
|
||||
desc := strings.TrimSpace(schemaItem.Desc)
|
||||
if desc == "" {
|
||||
desc = "无描述"
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%d. %s:%s", index, name, desc))
|
||||
|
||||
doc := parseExecuteToolSchema(schemaItem.SchemaText)
|
||||
paramSummary := renderExecuteToolParamSummary(doc.Parameters)
|
||||
lines = append(lines, " 参数:"+paramSummary)
|
||||
|
||||
returnType, returnSample := renderExecuteToolReturnHint(name)
|
||||
lines = append(lines, " 返回类型:"+returnType)
|
||||
if shouldRenderExecuteToolReturnSample(name) {
|
||||
lines = append(lines, " 返回示例:"+returnSample)
|
||||
}
|
||||
if callSample := renderExecuteToolCallHint(name); strings.TrimSpace(callSample) != "" {
|
||||
lines = append(lines, " 调用示例:"+callSample)
|
||||
}
|
||||
}
|
||||
|
||||
if index == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func shouldRenderExecuteToolReturnSample(toolName string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(toolName)) {
|
||||
case "query_available_slots",
|
||||
"query_target_tasks",
|
||||
"queue_pop_head",
|
||||
"queue_status",
|
||||
"queue_apply_head_move",
|
||||
"queue_skip_head",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
"analyze_health",
|
||||
"analyze_rhythm",
|
||||
"upsert_task_class":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func renderExecuteToolCallHint(toolName string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(toolName)) {
|
||||
case "upsert_task_class":
|
||||
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 ""
|
||||
}
|
||||
}
|
||||
|
||||
func renderExecuteToolReturnHint(toolName string) (returnType string, sample string) {
|
||||
returnType = "string(自然语言文本)"
|
||||
switch strings.ToLower(strings.TrimSpace(toolName)) {
|
||||
case "get_overview":
|
||||
return returnType, "规划窗口共27天...课程占位条目34个...任务清单(已过滤课程)..."
|
||||
case "get_task_info":
|
||||
return returnType, "[35] 第一章随机事件与概率 | 状态:已预排(suggested) | 占用时段:第3天第5-6节"
|
||||
case "query_available_slots":
|
||||
return "string(JSON字符串)", `{"tool":"query_available_slots","count":12,"strict_count":8,"embedded_count":4,"slots":[{"day":5,"week":12,"day_of_week":3,"slot_start":1,"slot_end":2,"slot_type":"empty"}]}`
|
||||
case "query_target_tasks":
|
||||
return "string(JSON字符串)", `{"tool":"query_target_tasks","count":6,"status":"suggested","enqueue":true,"enqueued":6,"queue":{"pending_count":6},"items":[{"task_id":35,"name":"示例任务","status":"suggested","slots":[{"day":3,"week":12,"day_of_week":1,"slot_start":5,"slot_end":6}]}]}`
|
||||
case "queue_pop_head":
|
||||
return "string(JSON字符串)", `{"tool":"queue_pop_head","has_head":true,"pending_count":5,"current":{"task_id":35,"name":"示例任务","status":"suggested","slots":[{"day":3,"week":12,"day_of_week":1,"slot_start":5,"slot_end":6}]}}`
|
||||
case "queue_status":
|
||||
return "string(JSON字符串)", `{"tool":"queue_status","pending_count":5,"completed_count":1,"skipped_count":0,"current_task_id":35,"current_attempt":1}`
|
||||
case "queue_apply_head_move":
|
||||
return "string(JSON字符串)", `{"tool":"queue_apply_head_move","success":true,"task_id":35,"pending_count":4,"completed_count":2,"result":"已将 [35]... 从第3天第5-6节移至第5天第3-4节。"}`
|
||||
case "queue_skip_head":
|
||||
return "string(JSON字符串)", `{"tool":"queue_skip_head","success":true,"skipped_task_id":35,"pending_count":4,"skipped_count":1}`
|
||||
case "query_range":
|
||||
return returnType, "第5天第3-6节:第3节空、第4节空..."
|
||||
case "place":
|
||||
return returnType, "已将 [35]... 预排到第5天第3-4节。"
|
||||
case "move":
|
||||
return returnType, "已将 [35]... 从第3天第5-6节移至第5天第3-4节。"
|
||||
case "swap":
|
||||
return returnType, "交换完成:[35]... ↔ [36]..."
|
||||
case "batch_move":
|
||||
return returnType, "批量移动完成,2 个任务全部成功。"
|
||||
case "unplace":
|
||||
return returnType, "已将 [35]... 移除,恢复为待安排状态。"
|
||||
case "web_search":
|
||||
return "string(JSON字符串)", `{"tool":"web_search","query":"检索关键词","count":2,"items":[{"title":"搜索结果标题","url":"https://example.com/page","snippet":"摘要片段...","domain":"example.com","published_at":"2025-04-10"}]}`
|
||||
case "web_fetch":
|
||||
return "string(JSON字符串)", `{"tool":"web_fetch","url":"https://example.com/page","title":"页面标题","content":"正文内容...","truncated":false}`
|
||||
case "analyze_health":
|
||||
return "string(JSON字符串)", `{"tool":"analyze_health","success":true,"metrics":{"rhythm":{"avg_switches_per_day":1.1,"max_switch_count":4,"heavy_adjacent_days":2,"same_type_transition_ratio":0.58,"block_balance":0,"fragmented_count":0,"compressed_run_count":0},"tightness":{"locally_movable_task_count":3,"avg_local_alternative_slots":1.7,"cross_class_swap_options":1,"forced_heavy_adjacent_days":0,"tightness_level":"tight"},"can_close":false},"decision":{"should_continue_optimize":true,"recommended_operation":"swap","primary_problem":"第4天存在高认知背靠背","candidates":[{"candidate_id":"swap_35_44","tool":"swap","arguments":{"task_a":35,"task_b":44}}]}}`
|
||||
case "analyze_rhythm":
|
||||
return "string(JSON字符串)", `{"tool":"analyze_rhythm","success":true,"metrics":{"overview":{"avg_switches_per_day":3.4,"max_switch_day":4,"max_switch_count":5,"heavy_adjacent_days":2,"long_high_intensity_days":1,"same_type_transition_ratio":0.42}}}`
|
||||
case "upsert_task_class":
|
||||
return "string(JSON字符串)", `{"tool":"upsert_task_class","success":true,"task_class_id":123,"created":true,"validation":{"ok":true,"issues":[]},"error":"","error_code":""}`
|
||||
default:
|
||||
return returnType, "自然语言结果(成功/失败原因/关键数据摘要)。"
|
||||
}
|
||||
}
|
||||
|
||||
func parseExecuteToolSchema(schemaText string) executeToolSchemaDoc {
|
||||
doc := executeToolSchemaDoc{Parameters: map[string]any{}}
|
||||
schemaText = strings.TrimSpace(schemaText)
|
||||
if schemaText == "" {
|
||||
return doc
|
||||
}
|
||||
if err := json.Unmarshal([]byte(schemaText), &doc); err != nil {
|
||||
return doc
|
||||
}
|
||||
if doc.Parameters == nil {
|
||||
doc.Parameters = map[string]any{}
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
func renderExecuteToolParamSummary(parameters map[string]any) string {
|
||||
if len(parameters) == 0 {
|
||||
return "{}"
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(parameters))
|
||||
for key := range parameters {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
status := "可选"
|
||||
typeText := ""
|
||||
|
||||
switch typed := parameters[key].(type) {
|
||||
case string:
|
||||
status = "必填"
|
||||
typeText = strings.TrimSpace(typed)
|
||||
case map[string]any:
|
||||
if required, ok := typed["required"].(bool); ok && required {
|
||||
status = "必填"
|
||||
}
|
||||
typeText = strings.TrimSpace(asExecuteString(typed["type"]))
|
||||
if enumRaw, ok := typed["enum"].([]any); ok && len(enumRaw) > 0 {
|
||||
enumText := make([]string, 0, len(enumRaw))
|
||||
for _, item := range enumRaw {
|
||||
enumText = append(enumText, fmt.Sprintf("%v", item))
|
||||
}
|
||||
if typeText == "" {
|
||||
typeText = "enum"
|
||||
}
|
||||
typeText += ":" + strings.Join(enumText, "/")
|
||||
}
|
||||
}
|
||||
|
||||
if typeText == "" {
|
||||
parts = append(parts, fmt.Sprintf("%s(%s)", key, status))
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s(%s,%s)", key, status, typeText))
|
||||
}
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
|
||||
// collectExecuteLoopRecords 从 history 里提取 thought + tool_call + observation 三元组。
|
||||
//
|
||||
// 1. 以 assistant tool_call 为主记录。
|
||||
// 2. 用 ToolCallID 去关联 tool observation,保证同轮绑定。
|
||||
// 3. thought 只向前取最近一条 assistant 纯文本消息,不跨越到更早的工具调用之前做复杂回溯。
|
||||
func collectExecuteLoopRecords(history []*schema.Message) []executeLoopRecord {
|
||||
if len(history) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
toolResultByCallID := make(map[string]*schema.Message, len(history))
|
||||
for _, msg := range history {
|
||||
if msg == nil || msg.Role != schema.Tool {
|
||||
continue
|
||||
}
|
||||
callID := strings.TrimSpace(msg.ToolCallID)
|
||||
if callID == "" {
|
||||
continue
|
||||
}
|
||||
toolResultByCallID[callID] = msg
|
||||
}
|
||||
|
||||
records := make([]executeLoopRecord, 0, len(history))
|
||||
for i, msg := range history {
|
||||
if msg == nil || msg.Role != schema.Assistant || len(msg.ToolCalls) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
thought := findExecuteThoughtBefore(history, i)
|
||||
for _, call := range msg.ToolCalls {
|
||||
toolName := strings.TrimSpace(call.Function.Name)
|
||||
if toolName == "" {
|
||||
toolName = "unknown_tool"
|
||||
}
|
||||
|
||||
toolArgs := compactExecuteText(call.Function.Arguments, 160)
|
||||
if toolArgs == "" {
|
||||
toolArgs = "{}"
|
||||
}
|
||||
|
||||
observation := "该工具调用尚未返回结果。"
|
||||
callID := strings.TrimSpace(call.ID)
|
||||
if callID != "" {
|
||||
if resultMsg, ok := toolResultByCallID[callID]; ok && resultMsg != nil {
|
||||
text := strings.TrimSpace(resultMsg.Content)
|
||||
if text != "" {
|
||||
observation = text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
records = append(records, executeLoopRecord{
|
||||
Thought: thought,
|
||||
ToolName: toolName,
|
||||
ToolArgs: toolArgs,
|
||||
Observation: observation,
|
||||
})
|
||||
}
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func findExecuteThoughtBefore(history []*schema.Message, index int) string {
|
||||
for i := index - 1; i >= 0; i-- {
|
||||
msg := history[i]
|
||||
if msg == nil || msg.Role != schema.Assistant {
|
||||
continue
|
||||
}
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
continue
|
||||
}
|
||||
content := compactExecuteText(msg.Content, 140)
|
||||
if content != "" {
|
||||
return content
|
||||
}
|
||||
}
|
||||
return "(未记录)"
|
||||
}
|
||||
|
||||
func renderExecuteToolCallText(toolName, toolArgs string) string {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName == "" {
|
||||
toolName = "unknown_tool"
|
||||
}
|
||||
toolArgs = strings.TrimSpace(toolArgs)
|
||||
if toolArgs == "" {
|
||||
toolArgs = "{}"
|
||||
}
|
||||
return toolName + "(" + toolArgs + ")"
|
||||
}
|
||||
|
||||
func hasExecuteRoughBuildDone(ctx *agentmodel.ConversationContext) bool {
|
||||
if ctx == nil {
|
||||
return false
|
||||
}
|
||||
for _, block := range ctx.PinnedBlocksSnapshot() {
|
||||
if strings.TrimSpace(block.Key) == "rough_build_done" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func renderExecuteLatestAnalyzeSummary(ctx *agentmodel.ConversationContext) string {
|
||||
record, ok := findExecuteLatestToolRecord(ctx, map[string]struct{}{
|
||||
"analyze_health": {},
|
||||
"analyze_rhythm": {},
|
||||
})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s -> %s", record.ToolName, record.Observation)
|
||||
}
|
||||
|
||||
func renderExecuteLatestMutationSummary(ctx *agentmodel.ConversationContext) string {
|
||||
record, ok := findExecuteLatestToolRecord(ctx, map[string]struct{}{
|
||||
"place": {},
|
||||
"move": {},
|
||||
"swap": {},
|
||||
"batch_move": {},
|
||||
"unplace": {},
|
||||
"queue_apply_head_move": {},
|
||||
})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s -> %s", record.ToolName, record.Observation)
|
||||
}
|
||||
|
||||
func findExecuteLatestToolRecord(ctx *agentmodel.ConversationContext, allowSet map[string]struct{}) (executeLatestToolRecord, bool) {
|
||||
if ctx == nil || len(allowSet) == 0 {
|
||||
return executeLatestToolRecord{}, false
|
||||
}
|
||||
history := ctx.HistorySnapshot()
|
||||
if len(history) == 0 {
|
||||
return executeLatestToolRecord{}, false
|
||||
}
|
||||
|
||||
toolNameByCallID := make(map[string]string, len(history))
|
||||
for _, msg := range history {
|
||||
if msg == nil || msg.Role != schema.Assistant || len(msg.ToolCalls) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, call := range msg.ToolCalls {
|
||||
callID := strings.TrimSpace(call.ID)
|
||||
toolName := strings.TrimSpace(call.Function.Name)
|
||||
if callID == "" || toolName == "" {
|
||||
continue
|
||||
}
|
||||
toolNameByCallID[callID] = toolName
|
||||
}
|
||||
}
|
||||
|
||||
for i := len(history) - 1; i >= 0; i-- {
|
||||
msg := history[i]
|
||||
if msg == nil || msg.Role != schema.Tool {
|
||||
continue
|
||||
}
|
||||
callID := strings.TrimSpace(msg.ToolCallID)
|
||||
if callID == "" {
|
||||
continue
|
||||
}
|
||||
toolName := strings.TrimSpace(toolNameByCallID[callID])
|
||||
if toolName == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowSet[toolName]; !ok {
|
||||
continue
|
||||
}
|
||||
return executeLatestToolRecord{
|
||||
ToolName: toolName,
|
||||
Observation: summarizeExecuteToolObservation(msg.Content),
|
||||
}, true
|
||||
}
|
||||
|
||||
return executeLatestToolRecord{}, false
|
||||
}
|
||||
|
||||
func summarizeExecuteToolObservation(raw string) string {
|
||||
content := strings.TrimSpace(raw)
|
||||
if content == "" {
|
||||
return "无返回内容。"
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(content), &payload); err == nil && len(payload) > 0 {
|
||||
if toolName := strings.TrimSpace(asExecuteString(payload["tool"])); toolName == "analyze_health" {
|
||||
return summarizeExecuteAnalyzeHealthObservationV2(payload)
|
||||
}
|
||||
for _, key := range []string{"result", "message", "reason", "error"} {
|
||||
if text := strings.TrimSpace(asExecuteString(payload[key])); text != "" {
|
||||
return compactExecuteText(text, 120)
|
||||
}
|
||||
}
|
||||
if success, ok := payload["success"].(bool); ok {
|
||||
if success {
|
||||
return "执行成功。"
|
||||
}
|
||||
return "执行失败。"
|
||||
}
|
||||
}
|
||||
|
||||
return compactExecuteText(content, 120)
|
||||
}
|
||||
|
||||
// collectExecuteConversationTurns 只提取 user 和 assistant speak。
|
||||
//
|
||||
// 1. 过滤 correction prompt,避免把后端纠错提示伪装成用户真实意图。
|
||||
// 2. 过滤 assistant tool_call 消息,避免 msg1 和 msg2 重复。
|
||||
// 3. 保持原始顺序,不在这里裁剪长度。
|
||||
func collectExecuteConversationTurns(history []*schema.Message) []conversationTurn {
|
||||
if len(history) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
turns := make([]conversationTurn, 0, len(history))
|
||||
for _, msg := range history {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(msg.Content)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
switch msg.Role {
|
||||
case schema.User:
|
||||
if isExecuteCorrectionPrompt(msg) {
|
||||
continue
|
||||
}
|
||||
turns = append(turns, conversationTurn{Role: "user", Content: text})
|
||||
case schema.Assistant:
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
continue
|
||||
}
|
||||
turns = append(turns, conversationTurn{Role: "assistant", Content: text})
|
||||
}
|
||||
}
|
||||
|
||||
return turns
|
||||
}
|
||||
|
||||
func isExecuteCorrectionPrompt(msg *schema.Message) bool {
|
||||
if msg == nil || msg.Role != schema.User {
|
||||
return false
|
||||
}
|
||||
if msg.Extra != nil {
|
||||
if kind, ok := msg.Extra[executeHistoryKindKey].(string); ok && strings.TrimSpace(kind) == executeHistoryKindCorrectionUser {
|
||||
return true
|
||||
}
|
||||
}
|
||||
content := strings.TrimSpace(msg.Content)
|
||||
return strings.Contains(content, "请重新分析当前状态,输出正确的内容。")
|
||||
}
|
||||
|
||||
func compactExecuteText(content string, maxLen int) string {
|
||||
content = firstExecuteLine(content)
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(content)
|
||||
if len(runes) <= maxLen {
|
||||
return content
|
||||
}
|
||||
if maxLen <= 3 {
|
||||
return string(runes[:maxLen])
|
||||
}
|
||||
return string(runes[:maxLen-3]) + "..."
|
||||
}
|
||||
|
||||
func firstExecuteLine(content string) string {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(content, "\n")
|
||||
return strings.TrimSpace(lines[0])
|
||||
}
|
||||
|
||||
func asExecuteString(value any) string {
|
||||
if text, ok := value.(string); ok {
|
||||
return text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func renderExecuteTaskClassIDs(state *agentmodel.CommonState) string {
|
||||
if state == nil || len(state.TaskClassIDs) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := make([]string, len(state.TaskClassIDs))
|
||||
for i, id := range state.TaskClassIDs {
|
||||
parts[i] = strconv.Itoa(id)
|
||||
}
|
||||
return fmt.Sprintf("task_class_ids=[%s]", strings.Join(parts, ","))
|
||||
}
|
||||
|
||||
// renderExecuteMemoryContext 复用统一记忆入口,避免 execute 私自拼接其他 pinned block。
|
||||
func renderExecuteMemoryContext(ctx *agentmodel.ConversationContext) string {
|
||||
return renderUnifiedMemoryContext(ctx)
|
||||
}
|
||||
|
||||
func renderTaskClassUpsertRuntime(state *agentmodel.CommonState) string {
|
||||
if state == nil || !state.TaskClassUpsertLastTried {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := make([]string, 0, 4)
|
||||
if state.TaskClassUpsertLastSuccess {
|
||||
lines = append(lines, "- 最近一次 upsert_task_class 成功。")
|
||||
} else {
|
||||
lines = append(lines, "- 最近一次 upsert_task_class 失败。")
|
||||
}
|
||||
if state.TaskClassUpsertConsecutiveFailures > 0 {
|
||||
lines = append(lines, fmt.Sprintf("- 连续失败次数:%d", state.TaskClassUpsertConsecutiveFailures))
|
||||
}
|
||||
if len(state.TaskClassUpsertLastIssues) > 0 {
|
||||
lines = append(lines, "- 需要优先处理 validation.issues:")
|
||||
for _, issue := range state.TaskClassUpsertLastIssues {
|
||||
trimmed := strings.TrimSpace(issue)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, " - "+trimmed)
|
||||
}
|
||||
}
|
||||
if !state.TaskClassUpsertLastSuccess {
|
||||
lines = append(lines, "- 写前最少检查项: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")
|
||||
}
|
||||
33
backend/services/agent/prompt/execute_context_health.go
Normal file
33
backend/services/agent/prompt/execute_context_health.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func fallbackExecuteText(value string, fallback string) string {
|
||||
if text := strings.TrimSpace(value); text != "" {
|
||||
return text
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func compactHealthAny(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
case bool:
|
||||
if typed {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case int:
|
||||
return fmt.Sprintf("%d", typed)
|
||||
case float64:
|
||||
return fmt.Sprintf("%.0f", typed)
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
}
|
||||
106
backend/services/agent/prompt/execute_context_health_v2.go
Normal file
106
backend/services/agent/prompt/execute_context_health_v2.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// summarizeExecuteAnalyzeHealthObservationV2 把 analyze_health 结果压成更短的单行摘要。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只保留 execute 下一步真正需要消费的裁决字段,不重复展开整份 metrics。
|
||||
// 2. 若存在候选,会优先展示“候选数量 + 前两个候选工具”,帮助模型迅速进入选择题。
|
||||
// 3. 这里只做摘要,不负责改变决策含义;真实判定仍以 analyze_health 原始 JSON 为准。
|
||||
func summarizeExecuteAnalyzeHealthObservationV2(payload map[string]any) string {
|
||||
decision, _ := payload["decision"].(map[string]any)
|
||||
metrics, _ := payload["metrics"].(map[string]any)
|
||||
rhythmMetrics, _ := metrics["rhythm"].(map[string]any)
|
||||
tightnessMetrics, _ := metrics["tightness"].(map[string]any)
|
||||
candidates, _ := decision["candidates"].([]any)
|
||||
|
||||
parts := make([]string, 0, 7)
|
||||
if text := compactHealthAny(decision["should_continue_optimize"]); text != "" {
|
||||
parts = append(parts, "continue="+text)
|
||||
}
|
||||
if text := strings.TrimSpace(asExecuteString(decision["recommended_operation"])); text != "" {
|
||||
parts = append(parts, "recommended="+text)
|
||||
}
|
||||
if text := strings.TrimSpace(asExecuteString(tightnessMetrics["tightness_level"])); text != "" {
|
||||
parts = append(parts, "tightness="+text)
|
||||
}
|
||||
if text := buildBlockBalanceSummary(rhythmMetrics); text != "" {
|
||||
parts = append(parts, text)
|
||||
}
|
||||
if text := compactHealthAny(decision["is_forced_imperfection"]); text != "" {
|
||||
parts = append(parts, "forced="+text)
|
||||
}
|
||||
if len(candidates) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("candidates=%d", len(candidates)))
|
||||
if preview := compactHealthCandidatePreview(candidates); preview != "" {
|
||||
parts = append(parts, "options="+preview)
|
||||
}
|
||||
}
|
||||
if text := strings.TrimSpace(asExecuteString(decision["primary_problem"])); text != "" {
|
||||
parts = append(parts, "problem="+compactExecuteText(text, 36))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "返回了健康裁决结果。"
|
||||
}
|
||||
return strings.Join(parts, " | ")
|
||||
}
|
||||
|
||||
// buildBlockBalanceSummary 把 block_balance 连同正负来源一起压成单段摘要。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只做 execute 摘要层的可读性补充,避免 LLM 只看到 balance=0 却看不到来源。
|
||||
// 2. 不改变 analyze_health 原始 JSON 结构;原始结构仍由 metrics.rhythm 提供完整字段。
|
||||
// 3. 若三个字段都缺失,则直接留空,避免构造误导性的默认值。
|
||||
func buildBlockBalanceSummary(rhythmMetrics map[string]any) string {
|
||||
if len(rhythmMetrics) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
blockBalance := compactHealthAny(rhythmMetrics["block_balance"])
|
||||
fragmentedCount := compactHealthAny(rhythmMetrics["fragmented_count"])
|
||||
compressedCount := compactHealthAny(rhythmMetrics["compressed_run_count"])
|
||||
if blockBalance == "" && fragmentedCount == "" && compressedCount == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"block_balance=%s(fragmented=%s,compressed=%s)",
|
||||
fallbackExecuteText(blockBalance, "?"),
|
||||
fallbackExecuteText(fragmentedCount, "?"),
|
||||
fallbackExecuteText(compressedCount, "?"),
|
||||
)
|
||||
}
|
||||
|
||||
func compactHealthCandidatePreview(candidates []any) string {
|
||||
if len(candidates) == 0 {
|
||||
return ""
|
||||
}
|
||||
preview := make([]string, 0, 2)
|
||||
for _, raw := range candidates {
|
||||
item, _ := raw.(map[string]any)
|
||||
if len(item) == 0 {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(asExecuteString(item["candidate_id"]))
|
||||
tool := strings.TrimSpace(asExecuteString(item["tool"]))
|
||||
if id == "" && tool == "" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case id != "" && tool != "":
|
||||
preview = append(preview, id+":"+tool)
|
||||
case id != "":
|
||||
preview = append(preview, id)
|
||||
default:
|
||||
preview = append(preview, tool)
|
||||
}
|
||||
if len(preview) >= 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(preview, ",")
|
||||
}
|
||||
104
backend/services/agent/prompt/execute_next_step_hint_v2.go
Normal file
104
backend/services/agent/prompt/execute_next_step_hint_v2.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agenttools "github.com/LoveLosita/smartflow/backend/services/agent/tools"
|
||||
)
|
||||
|
||||
// renderExecuteNextStepHintV2 生成 execute.msg3 的轻量方向提示。
|
||||
//
|
||||
// 设计目标:
|
||||
// 1. 主动优化模式下,只强调“先 analyze_health,再从 candidates 里选”,不再散发额外搜索暗示。
|
||||
// 2. 普通链路仍保留必要的业务引导,避免误伤用户明确提出的普通调整请求。
|
||||
// 3. 提示只给方向,不替模型代填最终写参数。
|
||||
func renderExecuteNextStepHintV2(
|
||||
state *agentmodel.CommonState,
|
||||
latestAnalyze string,
|
||||
latestMutation string,
|
||||
roughBuildDone bool,
|
||||
) string {
|
||||
if state == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
activeDomain := strings.TrimSpace(state.ActiveToolDomain)
|
||||
activePacks := agenttools.ResolveEffectiveToolPacks(state.ActiveToolDomain, state.ActiveToolPacks)
|
||||
|
||||
if state.ActiveOptimizeOnly {
|
||||
switch {
|
||||
case activeDomain == "" && roughBuildDone:
|
||||
return "当前是粗排后主动优化专用模式;先激活 schedule,并只围绕 analyze_health -> move/swap 候选闭环推进。"
|
||||
case !state.HealthCheckDone:
|
||||
return "当前是粗排后主动优化专用模式;先调 analyze_health,等待后端给出 candidates,再做选择。"
|
||||
case !state.HealthIsFeasible || strings.EqualFold(strings.TrimSpace(state.HealthRecommendedOperation), "ask_user"):
|
||||
return "analyze_health 已判定当前更像时间窗或信息约束问题;不要继续挪动,先把冲突或缺失点明确告诉用户。"
|
||||
case !state.HealthShouldContinueOptimize:
|
||||
return "analyze_health 已判定当前无需继续主动优化;若用户没有新增要求,直接收口。"
|
||||
default:
|
||||
return "当前是粗排后主动优化专用模式;直接从 analyze_health 的 decision.candidates 里选一个合法 move/swap 执行,不要再自己搜索读工具。"
|
||||
}
|
||||
}
|
||||
|
||||
if activeDomain == "schedule" && state.HealthCheckDone {
|
||||
switch {
|
||||
case !state.HealthShouldContinueOptimize && state.HealthIsForcedImperfection:
|
||||
return fmt.Sprintf(
|
||||
"analyze_health 已判定当前更像约束代价:tightness=%s,主问题=%s。优先考虑收口。",
|
||||
fallbackExecuteText(state.HealthTightnessLevel, "unknown"),
|
||||
fallbackExecuteText(state.HealthPrimaryProblem, "无"),
|
||||
)
|
||||
case !state.HealthShouldContinueOptimize:
|
||||
return fmt.Sprintf(
|
||||
"analyze_health 已判定当前没有更值得继续处理的局部问题:%s。若用户未追加新要求,优先收口。",
|
||||
fallbackExecuteText(state.HealthPrimaryProblem, "当前可直接收口"),
|
||||
)
|
||||
case state.HealthStagnationCount > 0:
|
||||
return fmt.Sprintf(
|
||||
"最近诊断已连续 %d 次无明显改善;若本轮仍不能让主问题变轻,优先收口。当前主问题:%s。",
|
||||
state.HealthStagnationCount,
|
||||
fallbackExecuteText(state.HealthPrimaryProblem, "无"),
|
||||
)
|
||||
case strings.EqualFold(strings.TrimSpace(state.HealthRecommendedOperation), "swap"):
|
||||
return fmt.Sprintf(
|
||||
"当前主问题:%s。优先在已有落位之间做局部 swap,别把问题扩散到更远的天数。",
|
||||
fallbackExecuteText(state.HealthPrimaryProblem, "无"),
|
||||
)
|
||||
case strings.EqualFold(strings.TrimSpace(state.HealthRecommendedOperation), "move"):
|
||||
return fmt.Sprintf(
|
||||
"当前主问题:%s。若要 move,只在近范围合法落点里小修,不要做全窗口搜索。",
|
||||
fallbackExecuteText(state.HealthPrimaryProblem, "无"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if activeDomain == "" {
|
||||
if roughBuildDone {
|
||||
return `先激活 schedule 业务域;当前是粗排后的微调场景,通常至少需要 mutation+analyze。若要按统一条件逐个处理一批任务,再加 packs=["queue"]。`
|
||||
}
|
||||
return `先判断当前任务属于哪个业务域,再用 context_tools_add 激活对应工具。若用户只是在描述学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,默认先走 taskclass;只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才切 schedule。`
|
||||
}
|
||||
|
||||
if activeDomain == "schedule" &&
|
||||
strings.Contains(latestMutation, "batch_move") &&
|
||||
(strings.Contains(latestMutation, "缺少") || strings.Contains(latestMutation, "无效")) {
|
||||
return `当前 batch_move 路径受参数约束;若要处理一批符合同一条件的任务,优先加 packs=["queue"] 逐个处理。`
|
||||
}
|
||||
|
||||
if activeDomain == "schedule" &&
|
||||
latestAnalyze != "" &&
|
||||
strings.Contains(latestAnalyze, "metrics") &&
|
||||
!containsExecutePack(activePacks, agenttools.ToolPackQueue) {
|
||||
return `若诊断已经完成,下一步应转入读事实或写操作,不要重复 analyze_health;涉及同类批量任务时优先考虑 packs=["queue"]。`
|
||||
}
|
||||
|
||||
if activeDomain == "taskclass" &&
|
||||
state.TaskClassUpsertLastTried &&
|
||||
!state.TaskClassUpsertLastSuccess {
|
||||
return `先判断 validation.issues 是“用户缺信息”还是“内部表示修正”;能从上下文补的先静默补齐,再用 confirm 重试 upsert_task_class,不要继续解释底层约束,更不要直接收口。`
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
350
backend/services/agent/prompt/execute_rule_packs.go
Normal file
350
backend/services/agent/prompt/execute_rule_packs.go
Normal file
@@ -0,0 +1,350 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agenttools "github.com/LoveLosita/smartflow/backend/services/agent/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
executeRulePackCoreMin = "core_min"
|
||||
executeRulePackSafetyHard = "safety_hard"
|
||||
executeRulePackContextProtocol = "context_protocol"
|
||||
executeRulePackModePlan = "mode_plan"
|
||||
executeRulePackModeReAct = "mode_react"
|
||||
executeRulePackDomainSchedule = "domain_schedule"
|
||||
executeRulePackDomainTaskClass = "domain_taskclass"
|
||||
executeRulePackScheduleMutation = "schedule_mutation"
|
||||
executeRulePackScheduleAnalyze = "schedule_analyze"
|
||||
executeRulePackScheduleWeb = "schedule_web"
|
||||
executeRulePackMicroRoughDone = "micro_rough_build_done"
|
||||
executeRulePackMicroDiagLoop = "micro_diag_tune_loop"
|
||||
executeRulePackMicroQueue = "micro_queue_chain"
|
||||
executeRulePackMicroTaskRetry = "micro_taskclass_retry"
|
||||
)
|
||||
|
||||
const executeSystemPromptBaseWithPlan = `
|
||||
你叫 SmartMate,是时伴(SmartMate)的中文 AI 排程伙伴,面向大学生提供陪伴式日程管理与日常协助。
|
||||
你擅长课表与任务安排、任务管理、学习规划和随口记,也可以正常回答日常问答、生活建议、信息整理、分析讨论等非排程问题。
|
||||
你的目标是像一个越用越懂用户的伙伴一样,结合历史对话、长期记忆和当前上下文,给出贴心、清晰、可信的帮助。
|
||||
你当前处于“计划执行”模式。你必须围绕当前计划步骤推进,并通过 SMARTFLOW_DECISION 输出结构化动作。`
|
||||
|
||||
const executeSystemPromptBaseReAct = `
|
||||
你叫 SmartMate,是时伴(SmartMate)的中文 AI 排程伙伴,面向大学生提供陪伴式日程管理与日常协助。
|
||||
你擅长课表与任务安排、任务管理、学习规划和随口记,也可以正常回答日常问答、生活建议、信息整理、分析讨论等非排程问题。
|
||||
你的目标是像一个越用越懂用户的伙伴一样,结合历史对话、长期记忆和当前上下文,给出贴心、清晰、可信的帮助。
|
||||
你当前处于“自由执行(ReAct)”模式。你需要根据当前目标自主推进、按需调用工具,并通过 SMARTFLOW_DECISION 输出结构化动作。`
|
||||
|
||||
type executeRulePack struct {
|
||||
Name string
|
||||
Content string
|
||||
}
|
||||
|
||||
// renderExecuteRulePackSection 渲染 execute.msg0 的动态规则包区域。
|
||||
//
|
||||
// 1. 这里负责“选哪些包 + 以什么顺序展示”,不负责工具目录本身。
|
||||
// 2. 固定先放通用硬约束,再放 mode/domain/micro 包,保证模型先读边界后读特例。
|
||||
// 3. 如果没有任何可展示规则包,则直接返回空串,避免无意义占位。
|
||||
func renderExecuteRulePackSection(state *agentmodel.CommonState, ctx *agentmodel.ConversationContext) (string, []string) {
|
||||
packs := selectExecuteRulePacks(state, ctx)
|
||||
if len(packs) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
lines := []string{"执行规则包(msg0 动态注入):"}
|
||||
names := make([]string, 0, len(packs))
|
||||
for _, pack := range packs {
|
||||
content := strings.TrimSpace(pack.Content)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("[%s]", pack.Name))
|
||||
lines = append(lines, content)
|
||||
names = append(names, pack.Name)
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return strings.Join(lines, "\n"), names
|
||||
}
|
||||
|
||||
func selectExecuteRulePacks(state *agentmodel.CommonState, ctx *agentmodel.ConversationContext) []executeRulePack {
|
||||
selected := make([]executeRulePack, 0, 8)
|
||||
seen := map[string]bool{}
|
||||
|
||||
appendPack := func(pack executeRulePack) {
|
||||
name := strings.TrimSpace(pack.Name)
|
||||
if name == "" || seen[name] {
|
||||
return
|
||||
}
|
||||
seen[name] = true
|
||||
selected = append(selected, pack)
|
||||
}
|
||||
|
||||
appendPack(buildExecuteCoreMinPack())
|
||||
appendPack(buildExecuteSafetyHardPack())
|
||||
appendPack(buildExecuteContextProtocolPack())
|
||||
|
||||
if state != nil && state.HasPlan() {
|
||||
appendPack(buildExecuteModePlanPack())
|
||||
} else {
|
||||
appendPack(buildExecuteModeReActPack())
|
||||
}
|
||||
|
||||
switch normalizeExecuteToolDomain(readExecuteActiveToolDomain(state)) {
|
||||
case "schedule":
|
||||
activePacks := readExecuteActiveToolPacks(state)
|
||||
appendPack(buildExecuteSchedulePack())
|
||||
if hasExecutePack(activePacks, agenttools.ToolPackQueue) {
|
||||
appendPack(buildExecuteQueueMicroPack())
|
||||
}
|
||||
if hasExecutePack(activePacks, agenttools.ToolPackMutation) {
|
||||
appendPack(buildExecuteScheduleMutationPack())
|
||||
}
|
||||
if hasExecutePack(activePacks, agenttools.ToolPackAnalyze) {
|
||||
appendPack(buildExecuteScheduleAnalyzePackV2())
|
||||
}
|
||||
if hasExecutePack(activePacks, agenttools.ToolPackWeb) {
|
||||
appendPack(buildExecuteScheduleWebPack())
|
||||
}
|
||||
case "taskclass":
|
||||
appendPack(buildExecuteTaskClassPack())
|
||||
}
|
||||
|
||||
if hasExecuteRoughBuildDone(ctx) {
|
||||
appendPack(buildExecuteRoughDoneMicroPack())
|
||||
}
|
||||
if shouldInjectExecuteDiagLoopPack(state, ctx) {
|
||||
appendPack(buildExecuteDiagLoopMicroPackV2())
|
||||
}
|
||||
if state != nil && state.TaskClassUpsertLastTried && !state.TaskClassUpsertLastSuccess {
|
||||
appendPack(buildExecuteTaskClassRetryMicroPack())
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
func readExecuteActiveToolDomain(state *agentmodel.CommonState) string {
|
||||
if state == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(state.ActiveToolDomain)
|
||||
}
|
||||
|
||||
func readExecuteActiveToolPacks(state *agentmodel.CommonState) []string {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
return agenttools.ResolveEffectiveToolPacks(state.ActiveToolDomain, state.ActiveToolPacks)
|
||||
}
|
||||
|
||||
func hasExecutePack(packs []string, target string) bool {
|
||||
target = strings.ToLower(strings.TrimSpace(target))
|
||||
if target == "" {
|
||||
return false
|
||||
}
|
||||
for _, pack := range packs {
|
||||
if strings.ToLower(strings.TrimSpace(pack)) == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// containsExecutePack 兼容旧调用点。
|
||||
//
|
||||
// 1. 这里只做别名转发,不引入第二套判断口径。
|
||||
// 2. 保留它是为了避免下一轮再因为历史调用点而误删。
|
||||
func containsExecutePack(packs []string, target string) bool {
|
||||
return hasExecutePack(packs, target)
|
||||
}
|
||||
|
||||
func normalizeExecuteToolDomain(domain string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(domain)) {
|
||||
case "schedule":
|
||||
return "schedule"
|
||||
case "taskclass":
|
||||
return "taskclass"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteCoreMinPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackCoreMin,
|
||||
Content: strings.TrimSpace(fmt.Sprintf(`
|
||||
- 当前时间锚点:%s。涉及“今天/明天/本周”等相对时间时,先按该锚点换算。
|
||||
- 用户意图优先:只推进用户当前明确要求;未明确部分先看能否从当前对话、历史、记忆、已知工具结果里静默补齐,只有补不出来时再 ask_user。
|
||||
- 域切换要克制:用户若只是在描述学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,这默认仍是 taskclass,不要主动切到 schedule。
|
||||
- 只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才允许进入 schedule 或触发粗排。
|
||||
- 先事实后动作:优先读工具补齐事实,再决定下一步。
|
||||
- 只要决定调用任何写工具,就必须输出 action=confirm;continue + 写工具无效。这个纪律同样适用于 upsert_task_class 的每一次重试。
|
||||
- 输出格式固定:先 <SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>,再输出用户可见正文。`,
|
||||
buildExecuteNowAnchorLine())),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteNowAnchorLine() string {
|
||||
now := time.Now()
|
||||
weekdays := []string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"}
|
||||
return fmt.Sprintf("%s(%s,%s)", now.Format("2006-01-02 15:04:05 -07:00"), weekdays[int(now.Weekday())], now.Format("MST"))
|
||||
}
|
||||
|
||||
func buildExecuteSafetyHardPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackSafetyHard,
|
||||
Content: strings.TrimSpace(`
|
||||
- 严禁伪造工具结果;若新结果与既有事实冲突,先重查一次再决定。
|
||||
- P1 阶段禁止调用 min_context_switch。
|
||||
- 工具参数必须严格使用 schema 字段名,禁止自造别名。
|
||||
- JSON 只保留当前 action 必需字段;不要输出空字符串、空对象、空数组或 null 占位。
|
||||
- 连续两轮同类读查询后,必须转执行 / ask_user / 明确说明阻塞,不能无限空转。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteContextProtocolPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackContextProtocol,
|
||||
Content: strings.TrimSpace(`
|
||||
- msg0 动态区初始仅保留 context_tools_add / context_tools_remove。
|
||||
- 需要业务工具前先 context_tools_add:排程用 domain="schedule",任务类写入用 domain="taskclass"。
|
||||
- 切 schedule 前先判断用户是否明确提出排程诉求;若只是描述任务类内容与排程偏好,先留在 taskclass。
|
||||
- schedule 可选 packs=["mutation","analyze","detail_read","deep_analyze","queue","web"];core 固定注入,不要显式传 core。
|
||||
- 只在业务方向切换时再 remove;done 后的动态区清理由系统自动完成,不必手动 remove。
|
||||
- 如果目标工具当前不在可用列表,先 add 对应 domain / packs,再继续执行。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteModePlanPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackModePlan,
|
||||
Content: strings.TrimSpace(`
|
||||
- 当前为计划执行模式:必须围绕当前计划步骤推进。
|
||||
- 未满足 done_when 时,只能 continue / confirm / ask_user,禁止 next_plan。
|
||||
- next_plan / done 时,goal_check 必须是字符串,并对照 done_when 给出完成证据。
|
||||
- 禁止跳步执行后续计划。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteModeReActPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackModeReAct,
|
||||
Content: strings.TrimSpace(`
|
||||
- 当前为自由执行(ReAct)模式:可自主决定 continue / confirm / ask_user / done / abort。
|
||||
- 如果关键事实既无法通过工具补齐,也无法从当前对话、历史、记忆中补齐,才 ask_user;不要把本可静默修正的内部表示问题转嫁给用户。
|
||||
- 自主推进时要小步快跑,优先闭合当前局部问题,不要发散成大范围开放搜索。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteSchedulePack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackDomainSchedule,
|
||||
Content: strings.TrimSpace(`
|
||||
- 当前业务域为 schedule:只处理当前目标任务类,不重排无关内容。
|
||||
- 只有用户已明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才应停留或切入 schedule。
|
||||
- 单纯看到总节数、难度、节次偏好、禁排时段、排除星期,不足以进入 schedule;这些默认仍属于 taskclass 约束。
|
||||
- existing 只作事实参考;真正可调对象优先看 suggested。
|
||||
- 同任务类内部顺序必须保持,任何越过前驱/后继边界的移动都会被写工具拒绝。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteScheduleMutationPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackScheduleMutation,
|
||||
Content: strings.TrimSpace(`
|
||||
- mutation 包负责真正落日程写操作:place / move / swap / batch_move / unplace。
|
||||
- 写操作必须走 action=confirm;不要在 continue 里偷跑写工具。
|
||||
- 若是主动优化链路,优先在后端给出的合法候选中选择,不要自己再全窗搜索新坑位。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteQueueMicroPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackMicroQueue,
|
||||
Content: strings.TrimSpace(`
|
||||
- queue 包适合“按同一条件逐个处理一批任务”的场景,例如把所有早八任务依次挪走。
|
||||
- query_target_tasks 可结合 enqueue=true 先把候选任务入队,再用 queue_pop_head / queue_apply_head_move / queue_skip_head 顺序处理。
|
||||
- 当你需要连续处理多条相似任务时,优先走 queue,避免把整批任务细节长期堆在上下文里。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteScheduleWebPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackScheduleWeb,
|
||||
Content: strings.TrimSpace(`
|
||||
- web 包只用于补充通用学习资料或通识信息,不用于捏造个人时间、考试时间、DDL 或排程事实。
|
||||
- web_search 先粗搜,web_fetch 再抓正文;不确定时宁可不用,也不要把网页结果当成排程事实直接写入。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteTaskClassPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackDomainTaskClass,
|
||||
Content: strings.TrimSpace(`
|
||||
- taskclass 域只负责生成或修正任务类,不代表已经开始排程。
|
||||
- 学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,默认都先落在 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 表示周一到周日。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteRoughDoneMicroPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackMicroRoughDone,
|
||||
Content: strings.TrimSpace(`
|
||||
- 已有 rough_build_done:本轮以微调为主,不要把任务重新当成“未排入”再全量 place。
|
||||
- 若当前问题已经可接受,应优先收口,不要为了追求完美继续反复局部打磨。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteTaskClassRetryMicroPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackMicroTaskRetry,
|
||||
Content: strings.TrimSpace(`
|
||||
- 最近一次 upsert_task_class 失败时,优先围绕 validation.issues 修补。
|
||||
- 先回到“写前检查”再决定是否重试:确认 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 补关键信息。`),
|
||||
}
|
||||
}
|
||||
|
||||
func shouldInjectExecuteDiagLoopPack(state *agentmodel.CommonState, ctx *agentmodel.ConversationContext) bool {
|
||||
if state == nil || !hasExecuteRoughBuildDone(ctx) {
|
||||
return false
|
||||
}
|
||||
if normalizeExecuteToolDomain(readExecuteActiveToolDomain(state)) != "schedule" {
|
||||
return false
|
||||
}
|
||||
activePacks := readExecuteActiveToolPacks(state)
|
||||
return hasExecutePack(activePacks, agenttools.ToolPackAnalyze) &&
|
||||
hasExecutePack(activePacks, agenttools.ToolPackMutation)
|
||||
}
|
||||
27
backend/services/agent/prompt/execute_rule_packs_health.go
Normal file
27
backend/services/agent/prompt/execute_rule_packs_health.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package agentprompt
|
||||
|
||||
import "strings"
|
||||
|
||||
func buildExecuteScheduleAnalyzePackV2() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackScheduleAnalyze,
|
||||
Content: strings.TrimSpace(`
|
||||
- analyze 包已激活:优先使用 analyze_health 判断“现在还值不值得继续主动优化”,不要把它当成全能体检表。
|
||||
- 若需要维度级细诊断(如 rhythm),再 add packs=["deep_analyze"],不要默认把所有分析都铺开。
|
||||
- 在主动优化专用模式里,analyze_health 会直接返回 decision.candidates:这些就是后端已经验证合法、并且复诊后确实变好的 move/swap 候选。
|
||||
- 一旦 decision.candidates 已经给出,下一步应直接从候选里选一个去执行;不要再自己搜索 query_target_tasks / query_available_slots。
|
||||
- 若 analyze_health 显示 should_continue_optimize=false,优先收口;不要因为“理论上还还能动”就继续局部修补。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteDiagLoopMicroPackV2() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackMicroDiagLoop,
|
||||
Content: strings.TrimSpace(`
|
||||
- 粗排后的主动优化允许多轮 execute,但每一轮都必须围绕“当前主问题”做局部、小范围、可解释的调整。
|
||||
- 在主动优化专用模式里,analyze_health 负责“出候选题”,你只负责在 decision.candidates 里做选择,不负责重新全窗搜点。
|
||||
- 若当前问题主要来自时间窗过紧,或所有合法候选都只是平移没有变轻,应接受局部不完美并收口。
|
||||
- 若连续两轮诊断没有明显改善,或当前 recommended_operation 已经是 close,应优先收口。
|
||||
- 主动优化优先在已有落位之间做选择:swap 优先,move 次之;不要做全窗口搜索。`),
|
||||
}
|
||||
}
|
||||
120
backend/services/agent/prompt/plan.go
Normal file
120
backend/services/agent/prompt/plan.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const planSystemPromptCore = `
|
||||
你是 SmartMate 的规划器(Planner),只负责规划,不负责执行。
|
||||
|
||||
最高优先级规则:
|
||||
1. 意图边界:只规划用户当前明确要求,禁止擅自扩展后续动作。
|
||||
2. 事实边界:禁止伪造工具调用、工具结果、外部事实和执行结论。
|
||||
3. 规划视角:先判断“最小工具闭环”再写步骤;不要先写抽象语义步骤,再让 execute 自己猜该怎么落工具。
|
||||
|
||||
规划规则:
|
||||
1. 每轮只做一次决策(continue / ask_user / plan_done)。
|
||||
2. 信息足够时优先 plan_done;信息不足时才 ask_user,且只问最小必要问题。
|
||||
3. action=plan_done 时必须返回完整 plan_steps(不是增量)。
|
||||
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 {
|
||||
parts := []string{
|
||||
strings.TrimSpace(planSystemPromptCore),
|
||||
BuildPlanDecisionContractText(),
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(parts, "\n\n"))
|
||||
}
|
||||
|
||||
// BuildPlanMessages 组装规划阶段的 messages。
|
||||
func BuildPlanMessages(state *agentmodel.CommonState, ctx *agentmodel.ConversationContext, userInput string) []*schema.Message {
|
||||
return buildUnifiedStageMessages(
|
||||
ctx,
|
||||
StageMessagesConfig{
|
||||
SystemPrompt: BuildPlanSystemPrompt(),
|
||||
Msg1Content: buildPlanConversationMessage(ctx),
|
||||
Msg2Content: buildPlanWorkspace(state),
|
||||
Msg3Suffix: BuildPlanUserPrompt(state, userInput),
|
||||
Msg3Role: schema.User,
|
||||
SkipBaseSystemPrompt: true,
|
||||
UseLiteToolCatalogMsg: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// BuildPlanUserPrompt 构造规划阶段的用户提示词。
|
||||
func BuildPlanUserPrompt(state *agentmodel.CommonState, userInput string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
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)
|
||||
if trimmedInput != "" {
|
||||
sb.WriteString("\n用户本轮输入:\n")
|
||||
sb.WriteString(trimmedInput)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// BuildPlanDecisionContractText 返回规划阶段的输出协议说明。
|
||||
func BuildPlanDecisionContractText() string {
|
||||
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"),
|
||||
agentmodel.PlanActionContinue,
|
||||
agentmodel.PlanActionAskUser,
|
||||
agentmodel.PlanActionDone,
|
||||
agentmodel.PlanActionDone,
|
||||
))
|
||||
}
|
||||
223
backend/services/agent/prompt/plan_context.go
Normal file
223
backend/services/agent/prompt/plan_context.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
)
|
||||
|
||||
// buildPlanConversationMessage 生成 plan 节点看到的真实对话视图。
|
||||
func buildPlanConversationMessage(ctx *agentmodel.ConversationContext) string {
|
||||
return buildConversationHistoryMessage(ctx, "规划参考对话")
|
||||
}
|
||||
|
||||
// buildPlanWorkspace 渲染 plan 节点自己的工作区。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 这里既保留“当前已有计划/任务类约束”,也显式补充“规划视角的工具摘要”;
|
||||
// 2. planner 需要先理解工具边界,才能把步骤收敛到最小闭环,而不是按抽象语义乱拆;
|
||||
// 3. 工具摘要不展开全量 schema,只提供规划真正需要的:负责什么、不负责什么、常见闭环、完成证据、域切换条件。
|
||||
func buildPlanWorkspace(state *agentmodel.CommonState) string {
|
||||
lines := []string{"规划工作区:"}
|
||||
if state == nil {
|
||||
lines = append(lines, "- 当前缺少流程状态,请主要依据最近对话与本轮输入继续规划。")
|
||||
lines = append(lines, buildPlanToolPlanningSummary())
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
if !state.HasPlan() {
|
||||
lines = append(lines, "- 当前还没有正式计划。")
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("- 已有计划:共 %d 步。", len(state.PlanSteps)))
|
||||
lines = append(lines, renderPlanCurrentStepSummary(state))
|
||||
lines = append(lines, "计划简表:")
|
||||
lines = append(lines, renderPlanStepOutline(state.PlanSteps))
|
||||
}
|
||||
|
||||
if taskClassIDs := renderPlanTaskClassIDs(state); taskClassIDs != "" {
|
||||
lines = append(lines, "- "+taskClassIDs)
|
||||
}
|
||||
if taskClassMeta := renderPlanTaskClassMeta(state); taskClassMeta != "" {
|
||||
lines = append(lines, "任务类约束:")
|
||||
lines = append(lines, taskClassMeta)
|
||||
}
|
||||
|
||||
lines = append(lines, buildPlanToolPlanningSummary())
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// renderPlanCurrentStepSummary 返回 plan 节点需要知道的当前步骤进度。
|
||||
func renderPlanCurrentStepSummary(state *agentmodel.CommonState) string {
|
||||
if state == nil || !state.HasPlan() {
|
||||
return "- 当前步骤:暂无。"
|
||||
}
|
||||
|
||||
current, total := state.PlanProgress()
|
||||
step, ok := state.CurrentPlanStep()
|
||||
if !ok {
|
||||
return fmt.Sprintf("- 当前步骤:计划共 %d 步,当前没有可继续沿用的有效步骤。", total)
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(step.Content)
|
||||
if content == "" {
|
||||
content = "(当前步骤正文为空)"
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("- 当前步骤:第 %d/%d 步,%s", current, total, content)
|
||||
if doneWhen := strings.TrimSpace(step.DoneWhen); doneWhen != "" {
|
||||
summary += fmt.Sprintf(";完成判定:%s", doneWhen)
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
// renderPlanStepOutline 将完整计划压成 plan 节点可读的简表。
|
||||
func renderPlanStepOutline(steps []agentmodel.PlanStep) string {
|
||||
if len(steps) == 0 {
|
||||
return "- 暂无。"
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(steps))
|
||||
for i, step := range steps {
|
||||
content := strings.TrimSpace(step.Content)
|
||||
if content == "" {
|
||||
content = "(步骤正文为空)"
|
||||
}
|
||||
line := fmt.Sprintf("%d. %s", i+1, content)
|
||||
if doneWhen := strings.TrimSpace(step.DoneWhen); doneWhen != "" {
|
||||
line += fmt.Sprintf(" | 完成判定:%s", doneWhen)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// renderPlanTaskClassIDs 返回批量排课场景下的 task_class_ids 简表。
|
||||
func renderPlanTaskClassIDs(state *agentmodel.CommonState) string {
|
||||
if state == nil || len(state.TaskClassIDs) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := make([]string, len(state.TaskClassIDs))
|
||||
for i, id := range state.TaskClassIDs {
|
||||
parts[i] = strconv.Itoa(id)
|
||||
}
|
||||
return fmt.Sprintf("task_class_ids=[%s]", strings.Join(parts, ", "))
|
||||
}
|
||||
|
||||
// renderPlanTaskClassMeta 返回 plan 节点真正需要看的任务类边界。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 这里只保留名称、策略、总时段、日期范围这类规划相关信息;
|
||||
// 2. 不再把所有字段原样平铺,避免工作区过胖;
|
||||
// 3. 若某项字段为空,则直接省略,不制造噪声。
|
||||
func renderPlanTaskClassMeta(state *agentmodel.CommonState) string {
|
||||
if state == nil || len(state.TaskClasses) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(state.TaskClasses))
|
||||
for _, tc := range state.TaskClasses {
|
||||
line := fmt.Sprintf("- [ID=%d] %s", tc.ID, strings.TrimSpace(tc.Name))
|
||||
if strategy := strings.TrimSpace(tc.Strategy); strategy != "" {
|
||||
line += fmt.Sprintf(";策略:%s", strategy)
|
||||
}
|
||||
if tc.TotalSlots > 0 {
|
||||
line += fmt.Sprintf(";总时段预算:%d", tc.TotalSlots)
|
||||
}
|
||||
if tc.StartDate != "" || tc.EndDate != "" {
|
||||
line += fmt.Sprintf(";日期范围:%s ~ %s", tc.StartDate, tc.EndDate)
|
||||
}
|
||||
if len(tc.ExcludedDaysOfWeek) > 0 {
|
||||
line += fmt.Sprintf(";排除星期:%v", tc.ExcludedDaysOfWeek)
|
||||
}
|
||||
if tc.SubjectType != "" || tc.DifficultyLevel != "" || tc.CognitiveIntensity != "" {
|
||||
line += fmt.Sprintf(";语义画像:%s/%s/%s",
|
||||
planSemanticValue(tc.SubjectType),
|
||||
planSemanticValue(tc.DifficultyLevel),
|
||||
planSemanticValue(tc.CognitiveIntensity),
|
||||
)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
return "未标注"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
106
backend/services/agent/prompt/quick_task.go
Normal file
106
backend/services/agent/prompt/quick_task.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const quickTaskSystemPrompt = `
|
||||
你是 SmartMate 的快捷任务助手。用户想记录或查看待办任务。你需要从用户消息中提取操作意图和参数。
|
||||
|
||||
你能做的操作:
|
||||
- create:记录一条新任务/提醒
|
||||
- query:查看/筛选任务列表
|
||||
|
||||
输出格式(两阶段):
|
||||
先输出一行决策标签,标签内是 JSON;标签之后换行输出给用户看的自然语言正文。
|
||||
决策标签格式:<SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>
|
||||
|
||||
JSON 字段说明:
|
||||
- action:只能是 create / query / ask
|
||||
- create 时:title 必填,deadline_at 必填,priority_group 必填,范围 1-4;estimated_sections 必填,范围 1-4,不确定默认 1;urgency_threshold_at 满足条件时填写,条件在下面
|
||||
- query 时:quadrant 可选 1-4,keyword 可选,limit 可选,deadline_after/deadline_before 可选(用于截止时间窗口筛选)
|
||||
- ask 时:question 必填
|
||||
|
||||
规则:
|
||||
1. 优先级:1=重要且紧急,2=重要不紧急,3=简单不重要,4=复杂不重要;紧急判定:截止时间距今不超过 48 小时为紧急(1),超过 48 小时为不紧急(2),无截止时间默认 3
|
||||
2. 信息不足时(如缺少 title),输出 {"action":"ask","question":"追问内容"}
|
||||
3. deadline_at 支持「明天下午3点」「下周一」「2026-04-20 18:00」等格式
|
||||
4. 未提供的可选字段直接省略,不要填 null 或空字符串
|
||||
5. JSON 中不要包含 speak 字段,给用户看的话放在 </SMARTFLOW_DECISION> 标签之后
|
||||
6. 紧急分界时间,即任务从"重要不紧急"自动轮换到"重要且紧急"的时间点;格式同 deadline_at ——当 priority_group=2 时必填,你必须根据 deadline 自动推算一个合理的紧急分界时间(通常为 deadline 前 24-48 小时),不要等用户提供;priority_group 为 1、3、4 或无截止时间时不要输出此字段
|
||||
7. query 里出现相对日期窗口(如"明天有什么事要做")时,优先输出明确边界:deadline_after="明天 00:00",deadline_before="后天 00:00",按 [after, before) 语义筛选
|
||||
|
||||
示例:
|
||||
|
||||
<SMARTFLOW_DECISION>{"action":"create","title":"明天开会","deadline_at":"明天下午3点","estimated_sections":1}</SMARTFLOW_DECISION>
|
||||
好的,我来帮你记一下。
|
||||
<SMARTFLOW_DECISION>{"action":"create","title":"下周交报告","deadline_at":"下周五 18:00","priority_group":2,"estimated_sections":2,"urgency_threshold_at":"下周四 09:00"}</SMARTFLOW_DECISION>
|
||||
好的,我也帮你记一下。
|
||||
|
||||
<SMARTFLOW_DECISION>{"action":"query","limit":5}</SMARTFLOW_DECISION>
|
||||
我帮你查一下当前的任务。
|
||||
|
||||
<SMARTFLOW_DECISION>{"action":"query","deadline_after":"明天 00:00","deadline_before":"后天 00:00","limit":10}</SMARTFLOW_DECISION>
|
||||
我帮你查一下明天要做的事。
|
||||
|
||||
<SMARTFLOW_DECISION>{"action":"ask","question":"你想记录什么呢?告诉我具体内容吧。"}</SMARTFLOW_DECISION>
|
||||
你想记录什么呢?告诉我具体内容吧。`
|
||||
|
||||
// BuildQuickTaskSystemPrompt 返回快捷任务阶段的系统提示词。
|
||||
func BuildQuickTaskSystemPrompt() string {
|
||||
return strings.TrimSpace(quickTaskSystemPrompt)
|
||||
}
|
||||
|
||||
// BuildQuickTaskMessagesSimple 组装快捷任务阶段的最简 messages(无对话历史)。
|
||||
func BuildQuickTaskMessagesSimple(userInput string) []*schema.Message {
|
||||
systemMsg := schema.SystemMessage(BuildQuickTaskSystemPrompt())
|
||||
userMsg := schema.UserMessage(buildQuickTaskUserPrompt(userInput))
|
||||
return []*schema.Message{systemMsg, userMsg}
|
||||
}
|
||||
|
||||
func buildQuickTaskUserPrompt(userInput string) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("当前时间=%s\n", time.Now().In(time.Local).Format("2006-01-02 15:04")))
|
||||
sb.WriteString("\n请从用户消息中提取操作意图和参数,严格按 SMARTFLOW_DECISION 标签格式输出。\n\n")
|
||||
|
||||
trimmedInput := strings.TrimSpace(userInput)
|
||||
if trimmedInput != "" {
|
||||
sb.WriteString("用户输入:\n")
|
||||
sb.WriteString(trimmedInput)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// BuildQuickTaskMessages 组装快捷任务阶段的完整 messages(含对话历史)。
|
||||
func BuildQuickTaskMessages(
|
||||
ctx *agentmodel.ConversationContext,
|
||||
userInput string,
|
||||
toolSchemas []agentmodel.ToolSchemaContext,
|
||||
) []*schema.Message {
|
||||
return buildUnifiedStageMessages(
|
||||
ctx,
|
||||
StageMessagesConfig{
|
||||
SystemPrompt: BuildQuickTaskSystemPrompt(),
|
||||
Msg1Content: buildChatConversationMessage(ctx),
|
||||
Msg2Content: buildQuickTaskWorkspace(toolSchemas),
|
||||
Msg3Suffix: buildQuickTaskUserPrompt(userInput),
|
||||
Msg3Role: schema.User,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func buildQuickTaskWorkspace(toolSchemas []agentmodel.ToolSchemaContext) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("可用工具:\n")
|
||||
for _, ts := range toolSchemas {
|
||||
sb.WriteString(fmt.Sprintf("- %s: %s\n 参数: %s\n", ts.Name, ts.Desc, ts.SchemaText))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
136
backend/services/agent/prompt/reasoning_summary.go
Normal file
136
backend/services/agent/prompt/reasoning_summary.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
reasoningSummaryMaxFullRunes = 6000
|
||||
reasoningSummaryMaxDeltaRunes = 1800
|
||||
)
|
||||
|
||||
// ReasoningSummaryPromptInput 描述一次“思考摘要”模型调用所需的最小输入。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只承载摘要模型需要看的文本与运行态,不绑定 stream 包的 DTO,避免 prompt 层反向依赖输出协议;
|
||||
// 2. FullReasoning 会在构造 prompt 时只保留尾部,避免长时间思考把便宜模型上下文撑爆;
|
||||
// 3. PreviousSummary 只作为连续摘要的参考,不要求模型逐字继承。
|
||||
type ReasoningSummaryPromptInput struct {
|
||||
FullReasoning string
|
||||
DeltaReasoning string
|
||||
PreviousSummary string
|
||||
CandidateSeq int
|
||||
Final bool
|
||||
DurationSeconds float64
|
||||
}
|
||||
|
||||
type reasoningSummaryPromptPayload struct {
|
||||
CandidateSeq int `json:"candidate_seq"`
|
||||
Final bool `json:"final"`
|
||||
DurationSeconds float64 `json:"duration_seconds"`
|
||||
PreviousSummary string `json:"previous_summary,omitempty"`
|
||||
RecentReasoning string `json:"recent_reasoning,omitempty"`
|
||||
DeltaReasoning string `json:"delta_reasoning,omitempty"`
|
||||
SourceTextRunes int `json:"source_text_runes,omitempty"`
|
||||
MaxDetailSummaryRunes int `json:"max_detail_summary_runes,omitempty"`
|
||||
}
|
||||
|
||||
// BuildReasoningSummaryMessages 构造思考摘要模型调用的 messages。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. system prompt 明确“只做用户可见摘要”,禁止复述原始思考链和内部推理细节;
|
||||
// 2. user prompt 使用 JSON 承载输入,便于后续扩展字段且减少模型误读;
|
||||
// 3. 长文本只保留尾部窗口,保证异步摘要请求稳定、便宜、可控。
|
||||
func BuildReasoningSummaryMessages(input ReasoningSummaryPromptInput) []*schema.Message {
|
||||
recentReasoning := trimRunesFromEnd(input.FullReasoning, reasoningSummaryMaxFullRunes)
|
||||
deltaReasoning := trimRunesFromEnd(input.DeltaReasoning, reasoningSummaryMaxDeltaRunes)
|
||||
payload := reasoningSummaryPromptPayload{
|
||||
CandidateSeq: input.CandidateSeq,
|
||||
Final: input.Final,
|
||||
DurationSeconds: input.DurationSeconds,
|
||||
PreviousSummary: strings.TrimSpace(input.PreviousSummary),
|
||||
RecentReasoning: recentReasoning,
|
||||
DeltaReasoning: deltaReasoning,
|
||||
SourceTextRunes: reasoningSummarySourceRunes(recentReasoning, deltaReasoning),
|
||||
MaxDetailSummaryRunes: ReasoningSummaryDetailRuneLimit(input.FullReasoning, input.DeltaReasoning),
|
||||
}
|
||||
|
||||
raw, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
raw = []byte(fmt.Sprintf(`{"recent_reasoning":%q}`, trimRunesFromEnd(input.FullReasoning, reasoningSummaryMaxFullRunes)))
|
||||
}
|
||||
|
||||
return []*schema.Message{
|
||||
schema.SystemMessage(buildReasoningSummarySystemPrompt()),
|
||||
schema.UserMessage("请基于 delta_reasoning 生成本轮新增的用户可见阶段摘要;recent_reasoning 仅作上下文,previous_summary 仅作去重参考。\n输入:\n" + string(raw)),
|
||||
}
|
||||
}
|
||||
|
||||
func buildReasoningSummarySystemPrompt() string {
|
||||
return strings.TrimSpace(`你是 SmartMate 的“思考摘要器”。你的任务是把模型内部 reasoning 整理成用户可见的进度摘要。
|
||||
|
||||
输出必须是严格 JSON 对象:
|
||||
{
|
||||
"short_summary": "8到18个汉字的短摘要",
|
||||
"detail_summary": "不超过 max_detail_summary_runes 个字的展开摘要"
|
||||
}
|
||||
|
||||
字段语义:
|
||||
- previous_summary:上一条已经展示给用户的摘要,只用于判断哪些内容已经说过。
|
||||
- delta_reasoning:本轮新增 reasoning,是生成 detail_summary 的主要依据。
|
||||
- recent_reasoning:全量尾部上下文,只用于补齐题目、变量名、阶段背景,不要按它重写一遍完整摘要。
|
||||
|
||||
规则:
|
||||
1. 不输出 markdown,不输出代码块,不解释 JSON 以外的内容。
|
||||
2. 摘要要像“阶段更新”,不是流水账;优先写新增结论、阶段变化、卡点、修正、下一步动作。
|
||||
3. detail_summary 以 delta_reasoning 为主;previous_summary 已覆盖的信息不要大段重复,除非本轮对它有修正或推进。
|
||||
4. short_summary 用 8 到 18 个汉字,偏结果或动作短语,例如“补齐边界条件”“转入代码实现”“优化滚动数组”。
|
||||
5. detail_summary 用自然的一到两句话表达,优先以具体对象、动作或结果开头,不要把“正在”“当前”“已确定”“已完成”作为默认句首模板;若 previous_summary 已使用类似开头,本轮必须换一种表达。
|
||||
6. final=false 时不要用“已完成”概括整体任务;只有 delta_reasoning 明确完成某个局部步骤时,才可描述该局部已经完成。
|
||||
7. detail_summary 字数必须小于等于 max_detail_summary_runes;不需要凑满上限,信息密度优先。
|
||||
8. 不暴露原始思考链、隐含假设链、逐步演算,只保留用户可见的进展。
|
||||
9. 若本轮没有实质新增信息,输出保守但不重复的摘要,例如“沿用上一轮判断,暂无新的可展示进展。”
|
||||
10. final=true 时,用完成态语气,说明思考已经收拢到下一步答复或动作。`)
|
||||
}
|
||||
|
||||
// ReasoningSummaryDetailRuneLimit 返回 detail_summary 的最大字数。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 与 BuildReasoningSummaryMessages 使用同一套输入窗口,避免 prompt 提示和服务端兜底口径不一致;
|
||||
// 2. 上限取“提供给摘要模型的主要文本段”的一半,并向上取整,适配极短文本;
|
||||
// 3. 返回 0 表示没有有效输入文本,调用方不应做硬裁剪。
|
||||
func ReasoningSummaryDetailRuneLimit(fullReasoning, deltaReasoning string) int {
|
||||
recentReasoning := trimRunesFromEnd(fullReasoning, reasoningSummaryMaxFullRunes)
|
||||
delta := trimRunesFromEnd(deltaReasoning, reasoningSummaryMaxDeltaRunes)
|
||||
sourceRunes := reasoningSummarySourceRunes(recentReasoning, delta)
|
||||
if sourceRunes <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (sourceRunes + 1) / 2
|
||||
}
|
||||
|
||||
func reasoningSummarySourceRunes(recentReasoning, deltaReasoning string) int {
|
||||
recentReasoning = strings.TrimSpace(recentReasoning)
|
||||
if recentReasoning != "" {
|
||||
return utf8.RuneCountInString(recentReasoning)
|
||||
}
|
||||
return utf8.RuneCountInString(strings.TrimSpace(deltaReasoning))
|
||||
}
|
||||
|
||||
func trimRunesFromEnd(text string, maxRunes int) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" || maxRunes <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
runes := []rune(text)
|
||||
if len(runes) <= maxRunes {
|
||||
return text
|
||||
}
|
||||
return string(runes[len(runes)-maxRunes:])
|
||||
}
|
||||
11
backend/services/agent/prompt/system.go
Normal file
11
backend/services/agent/prompt/system.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package agentprompt
|
||||
|
||||
const (
|
||||
// SystemPrompt 全局系统人设:定义 SmartMate 的基本调性
|
||||
SystemPrompt = `你叫 SmartMate,是时伴(SmartMate)的中文 AI 排程伙伴,面向大学生提供陪伴式日程管理与日常协助。
|
||||
你擅长课表与任务安排、任务管理、学习规划和随口记,也可以正常回答日常问答、生活建议、信息整理、分析讨论等非排程问题。
|
||||
你的目标是像一个越用越懂用户的伙伴一样,结合历史对话、长期记忆和当前上下文,给出贴心、清晰、可信的帮助。
|
||||
你的回复应当专业、自然、有陪伴感,偶尔可以带一点轻松幽默。
|
||||
如果用户的问题与日程无关,不要因为"不属于排程"就拒绝、回避或强行转到任务安排;只要不需要工具且你有把握,就直接回答。
|
||||
重要约束:你无法直接写入数据库。除非系统明确告知"任务已落库成功",否则禁止使用"已安排/已记录/已帮你记下"等完成态表述。`
|
||||
)
|
||||
273
backend/services/agent/prompt/unified_context.go
Normal file
273
backend/services/agent/prompt/unified_context.go
Normal file
@@ -0,0 +1,273 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// ConversationTurn 表示对话历史中的一轮自然语言交互。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只承载 user 与 assistant speak,不承载 tool_call 和 tool observation;
|
||||
// 2. 供 chat / plan / deliver 等节点复用,避免各节点重复写一套提取逻辑;
|
||||
// 3. 不负责裁剪长度,长度预算统一交给压缩层处理。
|
||||
type ConversationTurn struct {
|
||||
Role string
|
||||
Content string
|
||||
}
|
||||
|
||||
// StageMessagesConfig 描述统一四段式骨架下,各节点自行提供的内容块。
|
||||
//
|
||||
// 设计目标:
|
||||
// 1. 统一层只负责“四条消息怎么拼”,不再替节点决定“每条消息里该放什么”;
|
||||
// 2. Msg1 / Msg2 / Msg3Prefix / Msg3Suffix 都由节点自己渲染,避免 chat / plan / deliver 继续套 execute 的内容模板;
|
||||
// 3. memory_context 仍由统一层单入口注入到 msg3,避免多处重复注入。
|
||||
type StageMessagesConfig struct {
|
||||
// SystemPrompt 是节点自己的系统提示词。
|
||||
SystemPrompt string
|
||||
|
||||
// Msg1Content 是第 2 条 assistant 消息,通常放“节点想看的历史视图”。
|
||||
Msg1Content string
|
||||
|
||||
// Msg2Content 是第 3 条 assistant 消息,通常放“节点自己的工作区/补充约束”。
|
||||
Msg2Content string
|
||||
|
||||
// Msg3Prefix 是第 4 条消息中位于 memory_context 之前的内容。
|
||||
// 常见放法:阶段状态、规划工作区摘要、交付收口约束等。
|
||||
Msg3Prefix string
|
||||
|
||||
// Msg3Suffix 是第 4 条消息中位于 memory_context 之后的内容。
|
||||
// 对 user-role 节点来说,这里通常放最终用户指令,保证“用户输入收尾”。
|
||||
Msg3Suffix string
|
||||
|
||||
// Msg3Role 指定第 4 条消息的角色。
|
||||
// Execute 继续使用 system,其余节点一般使用 user。
|
||||
Msg3Role schema.RoleType
|
||||
|
||||
// SkipBaseSystemPrompt 为 true 时,msg0 只使用节点自己的 SystemPrompt,
|
||||
// 不再拼接 ConversationContext.SystemPrompt。
|
||||
SkipBaseSystemPrompt bool
|
||||
|
||||
// UseLiteToolCatalogMsg 为 true 时,msg0 工具目录采用轻量模式(仅名称与职责)。
|
||||
UseLiteToolCatalogMsg bool
|
||||
}
|
||||
|
||||
// buildUnifiedStageMessages 组装统一 4 段式消息骨架。
|
||||
//
|
||||
// 固定布局:
|
||||
// 1. msg0(system):系统规则 + 阶段规则 + 工具简表;
|
||||
// 2. msg1(assistant):节点自定义的历史视图;
|
||||
// 3. msg2(assistant):节点自定义的工作区;
|
||||
// 4. msg3(user/system):节点自定义前后缀 + 统一 memory_context。
|
||||
func buildUnifiedStageMessages(
|
||||
ctx *agentmodel.ConversationContext,
|
||||
config StageMessagesConfig,
|
||||
) []*schema.Message {
|
||||
msg0 := buildUnifiedMsg0(config.SystemPrompt, ctx, config.SkipBaseSystemPrompt, config.UseLiteToolCatalogMsg)
|
||||
msg1 := buildUnifiedMsg1(config.Msg1Content)
|
||||
msg2 := buildUnifiedMsg2(config.Msg2Content)
|
||||
msg3 := buildUnifiedMsg3(ctx, config)
|
||||
|
||||
return []*schema.Message{
|
||||
schema.SystemMessage(msg0),
|
||||
{Role: schema.Assistant, Content: msg1},
|
||||
{Role: schema.Assistant, Content: msg2},
|
||||
buildUnifiedMsg3Message(msg3, config.Msg3Role),
|
||||
}
|
||||
}
|
||||
|
||||
// buildUnifiedMsg3Message 根据配置决定第 4 条消息的角色。
|
||||
func buildUnifiedMsg3Message(content string, role schema.RoleType) *schema.Message {
|
||||
if role == schema.User {
|
||||
return schema.UserMessage(content)
|
||||
}
|
||||
return schema.SystemMessage(content)
|
||||
}
|
||||
|
||||
// buildUnifiedMsg0 合并系统提示 + 工具简表,生成 msg0。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先合并基础系统提示与节点系统提示,保证模型身份稳定;
|
||||
// 2. 若当前节点注入了工具 schema,则附加紧凑工具目录;
|
||||
// 3. 若两部分都为空,则回退到最小兜底提示,避免出现空消息。
|
||||
func buildUnifiedMsg0(stageSystemPrompt string, ctx *agentmodel.ConversationContext, skipBaseSystemPrompt bool, useLiteToolCatalog bool) string {
|
||||
base := ""
|
||||
if skipBaseSystemPrompt {
|
||||
base = strings.TrimSpace(stageSystemPrompt)
|
||||
} else {
|
||||
base = strings.TrimSpace(mergeSystemPrompts(ctx, stageSystemPrompt))
|
||||
}
|
||||
if base == "" {
|
||||
base = "你是 SmartMate 助手,请继续当前阶段。"
|
||||
}
|
||||
|
||||
toolCatalog := renderExecuteToolCatalogCompact(ctx, nil)
|
||||
if useLiteToolCatalog {
|
||||
toolCatalog = renderUnifiedToolCatalogLite(ctx)
|
||||
}
|
||||
if toolCatalog == "" {
|
||||
return base
|
||||
}
|
||||
return base + "\n\n" + toolCatalog
|
||||
}
|
||||
|
||||
// renderUnifiedToolCatalogLite 渲染统一阶段可用工具的轻量目录。
|
||||
//
|
||||
// 1. 只展示工具名和一句话职责,避免把 execute 的参数/返回示例污染到 plan/chat/deliver。
|
||||
// 2. 目录信息仅用于“能力边界感知”,不承担具体参数指导。
|
||||
// 3. 当工具数量过多时保留前若干项并给出省略提示,控制 msg0 体积。
|
||||
func renderUnifiedToolCatalogLite(ctx *agentmodel.ConversationContext) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
schemas := ctx.ToolSchemasSnapshot()
|
||||
if len(schemas) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
const maxItems = 18
|
||||
lines := []string{"当前可用工具(轻量目录):"}
|
||||
added := 0
|
||||
|
||||
for _, item := range schemas {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
desc := strings.TrimSpace(item.Desc)
|
||||
if desc == "" {
|
||||
lines = append(lines, fmt.Sprintf("- %s", name))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("- %s:%s", name, desc))
|
||||
}
|
||||
added++
|
||||
if added >= maxItems {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if added == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(schemas) > added {
|
||||
lines = append(lines, fmt.Sprintf("- 其余 %d 个工具已省略(按需再看)。", len(schemas)-added))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// buildUnifiedMsg1 返回节点自行提供的历史视图。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 统一层不再内置 execute 风格的 ReAct 摘要;
|
||||
// 2. 节点若未传入内容,则回退到最小占位,保证四段结构稳定;
|
||||
// 3. 压缩层仍会统一统计和压缩这条消息。
|
||||
func buildUnifiedMsg1(content string) string {
|
||||
content = strings.TrimSpace(content)
|
||||
if content != "" {
|
||||
return content
|
||||
}
|
||||
return "历史上下文:暂无。"
|
||||
}
|
||||
|
||||
// buildUnifiedMsg2 返回节点自行提供的工作区。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 非 execute 节点也允许有自己的 msg2,不再被统一层硬塞“暂无”语义;
|
||||
// 2. 若节点暂时没有额外工作区,则回退到最小占位,保证结构稳定。
|
||||
func buildUnifiedMsg2(content string) string {
|
||||
content = strings.TrimSpace(content)
|
||||
if content != "" {
|
||||
return content
|
||||
}
|
||||
return "阶段工作区:暂无。"
|
||||
}
|
||||
|
||||
// buildUnifiedMsg3 统一拼装 msg3:前缀 + memory_context + 后缀。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 前缀由节点决定,适合放轻量状态或阶段约束;
|
||||
// 2. memory_context 只在这里注入一次,避免 pinned block 多入口重复出现;
|
||||
// 3. 后缀由节点决定。对于 user-role 节点,通常把最终用户指令放在这里,保证消息末尾仍是用户输入。
|
||||
func buildUnifiedMsg3(ctx *agentmodel.ConversationContext, config StageMessagesConfig) string {
|
||||
var sections []string
|
||||
|
||||
if prefix := strings.TrimSpace(config.Msg3Prefix); prefix != "" {
|
||||
sections = append(sections, prefix)
|
||||
}
|
||||
if memoryText := renderUnifiedMemoryContext(ctx); memoryText != "" {
|
||||
sections = append(sections, "相关记忆(仅在确有帮助时参考,不要机械复述):\n"+memoryText)
|
||||
}
|
||||
if suffix := strings.TrimSpace(config.Msg3Suffix); suffix != "" {
|
||||
sections = append(sections, suffix)
|
||||
}
|
||||
|
||||
if len(sections) == 0 {
|
||||
return "请继续当前阶段。"
|
||||
}
|
||||
return strings.Join(sections, "\n\n")
|
||||
}
|
||||
|
||||
// renderUnifiedMemoryContext 提取需要补充到 msg3 的记忆文本。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 只消费 memory_context,避免把 execution_context / current_step 等阶段专属块混回 prompt;
|
||||
// 2. block 不存在或正文为空时直接返回空串;
|
||||
// 3. 这里只读取 agent/sv 已经产出的最终文本,不在这里重新拼装记忆。
|
||||
func renderUnifiedMemoryContext(ctx *agentmodel.ConversationContext) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
block, ok := ctx.PinnedBlockByKey("memory_context")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
content := strings.TrimSpace(block.Content)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
// CollectConversationTurns 从历史消息中提取 user + assistant speak 对话流。
|
||||
//
|
||||
// 提取规则:
|
||||
// 1. 只保留 user 消息(排除 correction prompt)和 assistant 纯文本消息;
|
||||
// 2. assistant tool_call 消息与 tool observation 消息不纳入“真实对话”;
|
||||
// 3. 返回顺序保持与原始 history 一致。
|
||||
func CollectConversationTurns(history []*schema.Message) []ConversationTurn {
|
||||
if len(history) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
turns := make([]ConversationTurn, 0, len(history))
|
||||
for _, msg := range history {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(msg.Content)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
switch msg.Role {
|
||||
case schema.User:
|
||||
// 1. 跳过后端注入的 correction prompt,避免把纠错文案误判为用户真实意图。
|
||||
if isExecuteCorrectionPrompt(msg) {
|
||||
continue
|
||||
}
|
||||
turns = append(turns, ConversationTurn{Role: "user", Content: text})
|
||||
case schema.Assistant:
|
||||
// 2. 跳过工具调用消息,只保留真正面向用户的 speak/答复。
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
continue
|
||||
}
|
||||
turns = append(turns, ConversationTurn{Role: "assistant", Content: text})
|
||||
}
|
||||
}
|
||||
|
||||
return turns
|
||||
}
|
||||
164
backend/services/agent/router/chat_route.go
Normal file
164
backend/services/agent/router/chat_route.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package agentrouter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
)
|
||||
|
||||
var (
|
||||
// chatRouteHeaderRegex 从模型流式输出中解析 SMARTFLOW_ROUTE 控制码头部。
|
||||
//
|
||||
// 格式示例:
|
||||
// <SMARTFLOW_ROUTE nonce="abc" route="execute" rough_build="true" refine="false" reorder="false" thinking="true"/>
|
||||
//
|
||||
// 属性说明:
|
||||
// 1. nonce:防注入校验,必须与调用方传入的 nonce 精确匹配;
|
||||
// 2. route:路由目标(direct_reply / execute / deep_answer / plan);
|
||||
// 3. rough_build:可选,仅 route=execute 时有效,默认 false;
|
||||
// 4. refine:可选,仅 rough_build=true 时有效,默认 false;
|
||||
// 5. reorder:可选,仅 route=execute 时有效,默认 false;
|
||||
// 6. thinking:可选,仅 route=execute 时有效,默认 false。
|
||||
chatRouteHeaderRegex = regexp.MustCompile(
|
||||
`(?is)<\s*SMARTFLOW_ROUTE\b` +
|
||||
`[^>]*\bnonce\s*=\s*["']?([a-zA-Z0-9\-]+)["']?` +
|
||||
`[^>]*\broute\s*=\s*["']?(direct_reply|execute|deep_answer|plan|quick_task)["']?` +
|
||||
`(?:[^>]*\brough_build\s*=\s*["']?(true|false)["']?)?` +
|
||||
`(?:[^>]*\brefine\s*=\s*["']?(true|false)["']?)?` +
|
||||
`(?:[^>]*\breorder\s*=\s*["']?(true|false)["']?)?` +
|
||||
`(?:[^>]*\bthinking\s*=\s*["']?(true|false)["']?)?` +
|
||||
`[^>]*/\s*>`)
|
||||
)
|
||||
|
||||
// StreamRouteParser 从 LLM 流式输出中增量提取路由决策。
|
||||
//
|
||||
// 协议约定:模型输出以 SMARTFLOW_ROUTE 控制码标签开头,标签结束后是用户可见内容。
|
||||
// 例如:<SMARTFLOW_ROUTE nonce="abc" route="direct_reply"/>你好!很高兴见到你...
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责从流式 chunk 中提取控制码并解析为 ChatRoutingDecision;
|
||||
// 2. 不负责推送 SSE chunk,不负责决定后续走哪条链路;
|
||||
// 3. 控制码解析失败时标记 fallback,由上层决定降级策略。
|
||||
type StreamRouteParser struct {
|
||||
buf strings.Builder
|
||||
nonce string
|
||||
routeFound bool
|
||||
decision *agentmodel.ChatRoutingDecision
|
||||
}
|
||||
|
||||
// NewStreamRouteParser 创建流式路由解析器。
|
||||
func NewStreamRouteParser(nonce string) *StreamRouteParser {
|
||||
return &StreamRouteParser{
|
||||
nonce: strings.ToLower(strings.TrimSpace(nonce)),
|
||||
}
|
||||
}
|
||||
|
||||
// Feed 写入一段 chunk content。
|
||||
//
|
||||
// 返回值:
|
||||
// - visible:控制码标签之后的内容(用户可见文本);
|
||||
// - routeReady:路由决策是否已确定;
|
||||
// - err:解析错误。
|
||||
//
|
||||
// 调用方应在 routeReady=true 后调用 Decision() 获取路由决策,
|
||||
// 并根据 route 进入对应分支处理 visible 及后续 chunk。
|
||||
func (p *StreamRouteParser) Feed(content string) (visible string, routeReady bool, err error) {
|
||||
if p.routeFound {
|
||||
// 路由已解析,后续 chunk 直接透传。
|
||||
return content, true, nil
|
||||
}
|
||||
|
||||
p.buf.WriteString(content)
|
||||
|
||||
text := p.buf.String()
|
||||
match := chatRouteHeaderRegex.FindStringSubmatchIndex(text)
|
||||
if match == nil {
|
||||
// 控制码尚未完整,检查是否应该 fallback。
|
||||
if len(text) > 500 {
|
||||
// 超过 500 字符仍未匹配到控制码 -> fallback 到 plan。
|
||||
p.routeFound = true
|
||||
p.decision = &agentmodel.ChatRoutingDecision{
|
||||
Route: agentmodel.ChatRoutePlan,
|
||||
Raw: text,
|
||||
}
|
||||
return text, true, fmt.Errorf("控制码解析超时,fallback 到 plan")
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// 提取匹配到的子组。
|
||||
groups := chatRouteHeaderRegex.FindStringSubmatch(text)
|
||||
if len(groups) < 3 {
|
||||
return "", false, fmt.Errorf("控制码正则子组不足: %d", len(groups))
|
||||
}
|
||||
|
||||
// nonce 校验。
|
||||
parsedNonce := strings.ToLower(strings.TrimSpace(groups[1]))
|
||||
if parsedNonce != p.nonce {
|
||||
return "", false, fmt.Errorf("nonce 不匹配: got=%s expected=%s", parsedNonce, p.nonce)
|
||||
}
|
||||
|
||||
// 解析 route。
|
||||
route := agentmodel.ChatRoute(strings.TrimSpace(groups[2]))
|
||||
|
||||
// 解析可选布尔属性(默认 false)。
|
||||
roughBuild := parseOptionalBool(groups, 3)
|
||||
refine := parseOptionalBool(groups, 4)
|
||||
reorder := parseOptionalBool(groups, 5)
|
||||
thinking := parseOptionalBool(groups, 6)
|
||||
|
||||
p.decision = &agentmodel.ChatRoutingDecision{
|
||||
Route: route,
|
||||
NeedsRoughBuild: roughBuild,
|
||||
NeedsRefineAfterRoughBuild: refine,
|
||||
AllowReorder: reorder,
|
||||
Thinking: thinking,
|
||||
Raw: groups[0],
|
||||
}
|
||||
|
||||
// 归一化与校验。
|
||||
if validateErr := p.decision.Validate(); validateErr != nil {
|
||||
// 校验失败 -> fallback 到 plan。
|
||||
p.decision.Route = agentmodel.ChatRoutePlan
|
||||
p.decision.NeedsRoughBuild = false
|
||||
p.decision.NeedsRefineAfterRoughBuild = false
|
||||
p.decision.AllowReorder = false
|
||||
p.decision.Thinking = false
|
||||
}
|
||||
|
||||
p.routeFound = true
|
||||
|
||||
// 控制码标签之后的文本作为 visible 返回。
|
||||
fullMatch := groups[0]
|
||||
tagEndIdx := strings.Index(text, fullMatch)
|
||||
if tagEndIdx >= 0 {
|
||||
afterTag := text[tagEndIdx+len(fullMatch):]
|
||||
// 去掉标签后紧跟的换行符(如果有)。
|
||||
afterTag = strings.TrimPrefix(afterTag, "\r\n")
|
||||
afterTag = strings.TrimPrefix(afterTag, "\n")
|
||||
return afterTag, true, nil
|
||||
}
|
||||
|
||||
return "", true, nil
|
||||
}
|
||||
|
||||
// RouteReady 返回路由决策是否已确定。
|
||||
func (p *StreamRouteParser) RouteReady() bool {
|
||||
return p.routeFound
|
||||
}
|
||||
|
||||
// Decision 返回已解析的路由决策(RouteReady=true 后可用)。
|
||||
func (p *StreamRouteParser) Decision() *agentmodel.ChatRoutingDecision {
|
||||
return p.decision
|
||||
}
|
||||
|
||||
// parseOptionalBool 从正则子组中解析可选布尔值。
|
||||
// 如果子组不存在或为空,返回 false。
|
||||
func parseOptionalBool(groups []string, index int) bool {
|
||||
if index >= len(groups) {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(groups[index]) == "true"
|
||||
}
|
||||
227
backend/services/agent/router/decision_parser.go
Normal file
227
backend/services/agent/router/decision_parser.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package agentrouter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
// decisionTagRegex 从模型流式输出中提取 <SMARTFLOW_DECISION>...</SMARTFLOW_DECISION> 标签。
|
||||
//
|
||||
// 格式示例:
|
||||
// <SMARTFLOW_DECISION>{"action":"continue","reason":"...","tool_call":{...}}</SMARTFLOW_DECISION>
|
||||
// 用户可见的友好文案在这里流式输出...
|
||||
//
|
||||
// 使用 (?s) dotall 模式使 . 匹配换行符(JSON 可能包含换行),
|
||||
// 非贪婪 (.*?) 避免匹配到多个标签时过度消耗。
|
||||
decisionTagRegex = regexp.MustCompile(
|
||||
`(?s)<\s*SMARTFLOW_DECISION\s*>(.*?)</\s*SMARTFLOW_DECISION\s*>`)
|
||||
// decisionTagHeadRegex 仅用于识别“起始标签是否已经出现”。
|
||||
// 目的:避免模型已经输出了 <SMARTFLOW_DECISION 开头但尚未输出闭合标签时,
|
||||
// 被长度阈值误判为 fallback(即“假截断”)。
|
||||
decisionTagHeadRegex = regexp.MustCompile(`(?i)<\s*SMARTFLOW_DECISION\b`)
|
||||
)
|
||||
|
||||
// StreamDecisionResult 描述解析器的最终输出状态。
|
||||
type StreamDecisionResult struct {
|
||||
// DecisionJSON 是标签内提取的完整 JSON 字符串。
|
||||
// 调用方应使用 llmservice.ParseJSONObject[T] 将其解析为具体决策类型。
|
||||
DecisionJSON string
|
||||
|
||||
// BeforeText 是 <SMARTFLOW_DECISION> 标签之前的自然语言前言。
|
||||
// 仅用于“标签后正文为空”时的兜底展示,不参与 JSON 解析。
|
||||
BeforeText string
|
||||
|
||||
// AfterText 是 </SMARTFLOW_DECISION> 标签之后的自然语言正文。
|
||||
// 这是主协议约定的用户可见文本来源。
|
||||
AfterText string
|
||||
|
||||
// Fallback=true 表示流中未找到决策标签(超过 500 字符阈值),
|
||||
// RawBuffer 包含全部累积文本,调用方应走 correction 路径。
|
||||
Fallback bool
|
||||
|
||||
// ParseFailed=true 表示找到了标签但内部 JSON 为空或括号计数提取失败,
|
||||
// RawBuffer 包含全部累积文本,调用方应走 correction 路径。
|
||||
ParseFailed bool
|
||||
|
||||
// RawBuffer 是流式累积的原始文本,用于 correction / 日志。
|
||||
RawBuffer string
|
||||
}
|
||||
|
||||
// StreamDecisionParser 从 LLM 流式输出中增量提取 <SMARTFLOW_DECISION> 标签内的 JSON。
|
||||
//
|
||||
// 协议约定:模型先输出 <SMARTFLOW_DECISION>{json}</SMARTFLOW_DECISION>,然后输出用户可见正文。
|
||||
// 调用方在 ready=true 后通过 DecisionJSON() 获取 JSON 字符串并自行解析,
|
||||
// 同一个 StreamReader 继续读取标签后的正文逐 token 推流。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责从流式 chunk 中提取决策标签和 JSON 字符串;
|
||||
// 2. 不负责 JSON 反序列化(由调用方用 ParseJSONObject 完成);
|
||||
// 3. 不负责推送 SSE chunk。
|
||||
type StreamDecisionParser struct {
|
||||
buf strings.Builder
|
||||
decisionFound bool
|
||||
decisionJSON string
|
||||
beforeText string
|
||||
afterText string
|
||||
rawBuf string // 用于 fallback/correction
|
||||
}
|
||||
|
||||
// NewStreamDecisionParser 创建决策标签流式解析器。
|
||||
func NewStreamDecisionParser() *StreamDecisionParser {
|
||||
return &StreamDecisionParser{}
|
||||
}
|
||||
|
||||
// Feed 写入一段 chunk content。
|
||||
//
|
||||
// 返回值:
|
||||
// - visible:决策标签之后的文本(用户可见内容,可能为空);
|
||||
// - ready:决策是否已提取完毕(成功或 fallback);
|
||||
// - err:非 nil 时表示 fallback 或解析失败。
|
||||
//
|
||||
// 调用方应在 ready=true 后:
|
||||
// 1. 调用 Result() 获取解析结果;
|
||||
// 2. 若 Fallback/ParseFailed 则走 correction 路径;
|
||||
// 3. 否则用 DecisionJSON 解析为具体决策类型;
|
||||
// 4. 继续读取同一个 reader,逐 token 推流 visible 及后续 chunk。
|
||||
func (p *StreamDecisionParser) Feed(content string) (visible string, ready bool, err error) {
|
||||
if p.decisionFound {
|
||||
return content, true, nil
|
||||
}
|
||||
|
||||
p.buf.WriteString(content)
|
||||
|
||||
text := p.buf.String()
|
||||
match := decisionTagRegex.FindStringSubmatchIndex(text)
|
||||
if match == nil {
|
||||
// 1. 标签尚未完整,检查 fallback 阈值。
|
||||
// 2. 仅当“完全没有出现起始标签”时才允许 fallback。
|
||||
// 3. 若已经出现起始标签但还没闭合,则继续等待后续 chunk,避免早退。
|
||||
if len(text) > 500 {
|
||||
if decisionTagHeadRegex.MatchString(text) {
|
||||
return "", false, nil
|
||||
}
|
||||
p.decisionFound = true
|
||||
p.rawBuf = text
|
||||
return text, true, fmt.Errorf("决策标签解析超时,未找到 SMARTFLOW_DECISION 标签")
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// 提取标签内文本(子组 1)。
|
||||
groups := decisionTagRegex.FindStringSubmatch(text)
|
||||
if len(groups) < 2 {
|
||||
p.decisionFound = true
|
||||
p.rawBuf = text
|
||||
return "", true, fmt.Errorf("决策标签正则子组不足")
|
||||
}
|
||||
|
||||
inner := groups[1]
|
||||
jsonStr := extractJSONFromTag(inner)
|
||||
if jsonStr == "" {
|
||||
p.decisionFound = true
|
||||
p.rawBuf = text
|
||||
return "", true, fmt.Errorf("决策标签内未找到有效 JSON")
|
||||
}
|
||||
|
||||
p.decisionFound = true
|
||||
p.decisionJSON = jsonStr
|
||||
p.rawBuf = text
|
||||
|
||||
// 1. 同时提取标签前/标签后的自然语言片段。
|
||||
// 2. 标签后正文仍然作为主协议 visible 返回,保持现有流式链路不变。
|
||||
// 3. 标签前前言只记入 Result,供 execute 在“后文为空”时兜底补发。
|
||||
fullMatch := groups[0]
|
||||
tagEndIdx := strings.Index(text, fullMatch)
|
||||
if tagEndIdx >= 0 {
|
||||
beforeTag := strings.TrimSpace(text[:tagEndIdx])
|
||||
afterTag := text[tagEndIdx+len(fullMatch):]
|
||||
afterTag = strings.TrimPrefix(afterTag, "\r\n")
|
||||
afterTag = strings.TrimPrefix(afterTag, "\n")
|
||||
p.beforeText = beforeTag
|
||||
p.afterText = afterTag
|
||||
return afterTag, true, nil
|
||||
}
|
||||
|
||||
return "", true, nil
|
||||
}
|
||||
|
||||
// Ready 返回决策是否已提取完毕。
|
||||
func (p *StreamDecisionParser) Ready() bool {
|
||||
return p.decisionFound
|
||||
}
|
||||
|
||||
// DecisionJSON 返回标签内提取的 JSON 字符串。
|
||||
// 仅在 Ready()=true 且 Result().Fallback=false && Result().ParseFailed=false 时有效。
|
||||
func (p *StreamDecisionParser) DecisionJSON() string {
|
||||
return p.decisionJSON
|
||||
}
|
||||
|
||||
// Result 返回完整解析结果,包含 fallback/parseFailed 状态和原始缓冲。
|
||||
func (p *StreamDecisionParser) Result() *StreamDecisionResult {
|
||||
r := &StreamDecisionResult{
|
||||
DecisionJSON: p.decisionJSON,
|
||||
BeforeText: p.beforeText,
|
||||
AfterText: p.afterText,
|
||||
RawBuffer: p.rawBuf,
|
||||
}
|
||||
if p.rawBuf != "" && p.decisionJSON == "" {
|
||||
// 没有提取到 JSON:判断是 fallback 还是 parseFailed。
|
||||
// fallback = buf 里根本没有标签;parseFailed = 有标签但 JSON 提取失败。
|
||||
if decisionTagRegex.FindStringSubmatchIndex(p.rawBuf) != nil {
|
||||
r.ParseFailed = true
|
||||
} else {
|
||||
r.Fallback = true
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// extractJSONFromTag 从标签内文本中提取第一个完整 JSON 对象。
|
||||
// 复用括号计数逻辑,与 llmservice.ExtractJSONObject 一致。
|
||||
func extractJSONFromTag(text string) string {
|
||||
clean := strings.TrimSpace(text)
|
||||
if clean == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
start := strings.Index(clean, "{")
|
||||
if start < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
depth := 0
|
||||
inString := false
|
||||
escaped := false
|
||||
for idx := start; idx < len(clean); idx++ {
|
||||
ch := clean[idx]
|
||||
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if ch == '\\' && inString {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if ch == '"' {
|
||||
inString = !inString
|
||||
continue
|
||||
}
|
||||
if inString {
|
||||
continue
|
||||
}
|
||||
|
||||
switch ch {
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return clean[start : idx+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
80
backend/services/agent/shared/clone.go
Normal file
80
backend/services/agent/shared/clone.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package agentshared
|
||||
|
||||
import "github.com/LoveLosita/smartflow/backend/model"
|
||||
|
||||
func CloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
dst := make([]model.UserWeekSchedule, 0, len(src))
|
||||
for _, week := range src {
|
||||
eventsCopy := make([]model.WeeklyEventBrief, len(week.Events))
|
||||
copy(eventsCopy, week.Events)
|
||||
dst = append(dst, model.UserWeekSchedule{
|
||||
Week: week.Week,
|
||||
Events: eventsCopy,
|
||||
})
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func CloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
dst := make([]model.HybridScheduleEntry, len(src))
|
||||
copy(dst, src)
|
||||
return dst
|
||||
}
|
||||
|
||||
func CloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
dst := make([]model.TaskClassItem, 0, len(src))
|
||||
for _, item := range src {
|
||||
copied := item
|
||||
if item.CategoryID != nil {
|
||||
v := *item.CategoryID
|
||||
copied.CategoryID = &v
|
||||
}
|
||||
if item.Order != nil {
|
||||
v := *item.Order
|
||||
copied.Order = &v
|
||||
}
|
||||
if item.Content != nil {
|
||||
v := *item.Content
|
||||
copied.Content = &v
|
||||
}
|
||||
if item.Status != nil {
|
||||
v := *item.Status
|
||||
copied.Status = &v
|
||||
}
|
||||
if item.EmbeddedTime != nil {
|
||||
t := *item.EmbeddedTime
|
||||
copied.EmbeddedTime = &t
|
||||
}
|
||||
dst = append(dst, copied)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func CloneInts(src []int) []int {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
dst := make([]int, len(src))
|
||||
copy(dst, src)
|
||||
return dst
|
||||
}
|
||||
|
||||
func CloneStrings(src []string) []string {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
dst := make([]string, len(src))
|
||||
copy(dst, src)
|
||||
return dst
|
||||
}
|
||||
366
backend/services/agent/shared/deadline.go
Normal file
366
backend/services/agent/shared/deadline.go
Normal file
@@ -0,0 +1,366 @@
|
||||
package agentshared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
deadlineLayouts = []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02 15:04",
|
||||
"2006/01/02 15:04:05",
|
||||
"2006/01/02 15:04",
|
||||
"2006.01.02 15:04:05",
|
||||
"2006.01.02 15:04",
|
||||
"2006-01-02",
|
||||
"2006/01/02",
|
||||
"2006.01.02",
|
||||
}
|
||||
deadlineDateOnlyLayouts = map[string]struct{}{
|
||||
"2006-01-02": {},
|
||||
"2006/01/02": {},
|
||||
"2006.01.02": {},
|
||||
}
|
||||
|
||||
clockHMRegex = regexp.MustCompile(`(\d{1,2})\s*[::]\s*(\d{1,2})`)
|
||||
clockCNRegex = regexp.MustCompile(`(\d{1,2})\s*点\s*(半|(\d{1,2})\s*分?)?`)
|
||||
ymdRegex = regexp.MustCompile(`(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?`)
|
||||
mdRegex = regexp.MustCompile(`(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?`)
|
||||
dateSepRegex = regexp.MustCompile(`\d{1,4}\s*[-/.]\s*\d{1,2}(\s*[-/.]\s*\d{1,2})?`)
|
||||
weekdayRegex = regexp.MustCompile(`(下周|下星期|下礼拜|本周|这周|本星期|这星期|周|星期|礼拜)([一二三四五六日天])`)
|
||||
relativeTokens = []string{
|
||||
"今天", "今日", "今晚", "今早", "今晨", "明天", "明日", "后天", "大后天", "昨天", "昨日",
|
||||
"早上", "早晨", "上午", "中午", "下午", "晚上", "傍晚", "夜里", "凌晨",
|
||||
}
|
||||
)
|
||||
|
||||
// ParseOptionalDeadline 解析工具输入中的可选截止时间。
|
||||
func ParseOptionalDeadline(raw string) (*time.Time, error) {
|
||||
value := normalizeDeadlineInput(raw)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
deadline, hasHint, err := parseDeadlineFromText(value, NowToMinute())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if deadline == nil {
|
||||
if !hasHint {
|
||||
return nil, fmt.Errorf("deadline_at 格式不支持: %s", value)
|
||||
}
|
||||
return nil, fmt.Errorf("deadline_at 无法解析: %s", value)
|
||||
}
|
||||
return deadline, nil
|
||||
}
|
||||
|
||||
// ParseOptionalDeadlineWithNow 在给定时间基准下解析 deadline。
|
||||
func ParseOptionalDeadlineWithNow(raw string, now time.Time) (*time.Time, error) {
|
||||
value := normalizeDeadlineInput(raw)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
deadline, _, err := parseDeadlineFromText(value, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if deadline == nil {
|
||||
return nil, fmt.Errorf("deadline_at 格式不支持: %s", value)
|
||||
}
|
||||
return deadline, nil
|
||||
}
|
||||
|
||||
func parseDeadlineFromText(value string, now time.Time) (*time.Time, bool, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
loc := ShanghaiLocation()
|
||||
now = now.In(loc)
|
||||
hasHint := hasDeadlineHint(value)
|
||||
|
||||
if abs, ok := tryParseAbsoluteDeadline(value, loc); ok {
|
||||
return abs, true, nil
|
||||
}
|
||||
if rel, recognized, err := tryParseRelativeDeadline(value, now, loc); recognized {
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
return rel, true, nil
|
||||
}
|
||||
if hasHint {
|
||||
return nil, true, fmt.Errorf("deadline_at 格式不支持: %s", value)
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func normalizeDeadlineInput(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
replacer := strings.NewReplacer(
|
||||
":", ":",
|
||||
",", ",",
|
||||
"。", ".",
|
||||
" ", " ",
|
||||
)
|
||||
return strings.TrimSpace(replacer.Replace(trimmed))
|
||||
}
|
||||
|
||||
func hasDeadlineHint(value string) bool {
|
||||
if clockHMRegex.MatchString(value) ||
|
||||
clockCNRegex.MatchString(value) ||
|
||||
ymdRegex.MatchString(value) ||
|
||||
mdRegex.MatchString(value) ||
|
||||
dateSepRegex.MatchString(value) ||
|
||||
weekdayRegex.MatchString(value) {
|
||||
return true
|
||||
}
|
||||
for _, token := range relativeTokens {
|
||||
if strings.Contains(value, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func tryParseAbsoluteDeadline(value string, loc *time.Location) (*time.Time, bool) {
|
||||
for _, layout := range deadlineLayouts {
|
||||
var (
|
||||
parsed time.Time
|
||||
err error
|
||||
)
|
||||
if layout == time.RFC3339 {
|
||||
parsed, err = time.Parse(layout, value)
|
||||
if err == nil {
|
||||
parsed = parsed.In(loc)
|
||||
}
|
||||
} else {
|
||||
parsed, err = time.ParseInLocation(layout, value, loc)
|
||||
}
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, dateOnly := deadlineDateOnlyLayouts[layout]; dateOnly {
|
||||
parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 0, 0, loc)
|
||||
} else {
|
||||
parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), parsed.Hour(), parsed.Minute(), 0, 0, loc)
|
||||
}
|
||||
return &parsed, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func tryParseRelativeDeadline(value string, now time.Time, loc *time.Location) (*time.Time, bool, error) {
|
||||
baseDate, recognized := inferBaseDate(value, now, loc)
|
||||
if !recognized {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
hour, minute, hasExplicitClock, err := extractClock(value)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
if !hasExplicitClock {
|
||||
hour, minute = defaultClockByHint(value)
|
||||
}
|
||||
|
||||
deadline := time.Date(baseDate.Year(), baseDate.Month(), baseDate.Day(), hour, minute, 0, 0, loc)
|
||||
return &deadline, true, nil
|
||||
}
|
||||
|
||||
func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time, bool) {
|
||||
if matched := ymdRegex.FindStringSubmatch(value); len(matched) == 4 {
|
||||
year, _ := strconv.Atoi(matched[1])
|
||||
month, _ := strconv.Atoi(matched[2])
|
||||
day, _ := strconv.Atoi(matched[3])
|
||||
if isValidDate(year, month, day) {
|
||||
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc), true
|
||||
}
|
||||
}
|
||||
|
||||
if matched := mdRegex.FindStringSubmatch(value); len(matched) == 3 {
|
||||
month, _ := strconv.Atoi(matched[1])
|
||||
day, _ := strconv.Atoi(matched[2])
|
||||
year := now.Year()
|
||||
if !isValidDate(year, month, day) {
|
||||
return time.Time{}, false
|
||||
}
|
||||
candidate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc)
|
||||
if candidate.Before(startOfDay(now)) {
|
||||
year++
|
||||
if !isValidDate(year, month, day) {
|
||||
return time.Time{}, false
|
||||
}
|
||||
candidate = time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc)
|
||||
}
|
||||
return candidate, true
|
||||
}
|
||||
|
||||
if matched := weekdayRegex.FindStringSubmatch(value); len(matched) == 3 {
|
||||
prefix := matched[1]
|
||||
target, ok := toWeekday(matched[2])
|
||||
if ok {
|
||||
return resolveWeekdayDate(now, prefix, target), true
|
||||
}
|
||||
}
|
||||
|
||||
today := startOfDay(now)
|
||||
switch {
|
||||
case strings.Contains(value, "大后天"):
|
||||
return today.AddDate(0, 0, 3), true
|
||||
case strings.Contains(value, "后天"):
|
||||
return today.AddDate(0, 0, 2), true
|
||||
case strings.Contains(value, "明天") || strings.Contains(value, "明日"):
|
||||
return today.AddDate(0, 0, 1), true
|
||||
case strings.Contains(value, "今天") || strings.Contains(value, "今日") || strings.Contains(value, "今晚") || strings.Contains(value, "今早") || strings.Contains(value, "今晨"):
|
||||
return today, true
|
||||
case strings.Contains(value, "昨天") || strings.Contains(value, "昨日"):
|
||||
return today.AddDate(0, 0, -1), true
|
||||
default:
|
||||
return time.Time{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func extractClock(value string) (int, int, bool, error) {
|
||||
hour := 0
|
||||
minute := 0
|
||||
hasClock := false
|
||||
|
||||
if matched := clockHMRegex.FindStringSubmatch(value); len(matched) == 3 {
|
||||
h, errH := strconv.Atoi(matched[1])
|
||||
m, errM := strconv.Atoi(matched[2])
|
||||
if errH != nil || errM != nil {
|
||||
return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value)
|
||||
}
|
||||
hour = h
|
||||
minute = m
|
||||
hasClock = true
|
||||
} else if matched := clockCNRegex.FindStringSubmatch(value); len(matched) >= 2 {
|
||||
h, errH := strconv.Atoi(matched[1])
|
||||
if errH != nil {
|
||||
return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value)
|
||||
}
|
||||
hour = h
|
||||
minute = 0
|
||||
hasClock = true
|
||||
if len(matched) >= 3 {
|
||||
if matched[2] == "半" {
|
||||
minute = 30
|
||||
} else if len(matched) >= 4 && strings.TrimSpace(matched[3]) != "" {
|
||||
m, errM := strconv.Atoi(strings.TrimSpace(matched[3]))
|
||||
if errM != nil {
|
||||
return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value)
|
||||
}
|
||||
minute = m
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasClock {
|
||||
return 0, 0, false, nil
|
||||
}
|
||||
|
||||
if isPMHint(value) && hour < 12 {
|
||||
hour += 12
|
||||
}
|
||||
if isNoonHint(value) && hour >= 1 && hour <= 10 {
|
||||
hour += 12
|
||||
}
|
||||
if strings.Contains(value, "凌晨") && hour == 12 {
|
||||
hour = 0
|
||||
}
|
||||
|
||||
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
|
||||
return 0, 0, true, fmt.Errorf("deadline_at 时间超出范围: %s", value)
|
||||
}
|
||||
return hour, minute, true, nil
|
||||
}
|
||||
|
||||
func defaultClockByHint(value string) (int, int) {
|
||||
switch {
|
||||
case strings.Contains(value, "凌晨"):
|
||||
return 1, 0
|
||||
case strings.Contains(value, "早上") || strings.Contains(value, "早晨") || strings.Contains(value, "上午") || strings.Contains(value, "今早") || strings.Contains(value, "明早"):
|
||||
return 9, 0
|
||||
case strings.Contains(value, "中午"):
|
||||
return 12, 0
|
||||
case strings.Contains(value, "下午"):
|
||||
return 15, 0
|
||||
case strings.Contains(value, "晚上") || strings.Contains(value, "今晚") || strings.Contains(value, "傍晚") || strings.Contains(value, "夜里"):
|
||||
return 20, 0
|
||||
default:
|
||||
return 23, 59
|
||||
}
|
||||
}
|
||||
|
||||
func isPMHint(value string) bool {
|
||||
return strings.Contains(value, "下午") || strings.Contains(value, "晚上") || strings.Contains(value, "今晚") || strings.Contains(value, "傍晚")
|
||||
}
|
||||
|
||||
func isNoonHint(value string) bool {
|
||||
return strings.Contains(value, "中午")
|
||||
}
|
||||
|
||||
func startOfDay(t time.Time) time.Time {
|
||||
loc := t.Location()
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
|
||||
}
|
||||
|
||||
func isValidDate(year, month, day int) bool {
|
||||
if month < 1 || month > 12 || day < 1 || day > 31 {
|
||||
return false
|
||||
}
|
||||
candidate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
|
||||
return candidate.Year() == year && int(candidate.Month()) == month && candidate.Day() == day
|
||||
}
|
||||
|
||||
func toWeekday(chinese string) (time.Weekday, bool) {
|
||||
switch chinese {
|
||||
case "一":
|
||||
return time.Monday, true
|
||||
case "二":
|
||||
return time.Tuesday, true
|
||||
case "三":
|
||||
return time.Wednesday, true
|
||||
case "四":
|
||||
return time.Thursday, true
|
||||
case "五":
|
||||
return time.Friday, true
|
||||
case "六":
|
||||
return time.Saturday, true
|
||||
case "日", "天":
|
||||
return time.Sunday, true
|
||||
default:
|
||||
return time.Sunday, false
|
||||
}
|
||||
}
|
||||
|
||||
func resolveWeekdayDate(now time.Time, prefix string, target time.Weekday) time.Time {
|
||||
today := startOfDay(now)
|
||||
weekdayOffset := (int(today.Weekday()) + 6) % 7
|
||||
weekStart := today.AddDate(0, 0, -weekdayOffset)
|
||||
targetOffset := (int(target) + 6) % 7
|
||||
candidateThisWeek := weekStart.AddDate(0, 0, targetOffset)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(prefix, "下"):
|
||||
return candidateThisWeek.AddDate(0, 0, 7)
|
||||
case strings.HasPrefix(prefix, "本"), strings.HasPrefix(prefix, "这"):
|
||||
return candidateThisWeek
|
||||
default:
|
||||
if candidateThisWeek.Before(today) {
|
||||
return candidateThisWeek.AddDate(0, 0, 7)
|
||||
}
|
||||
return candidateThisWeek
|
||||
}
|
||||
}
|
||||
117
backend/services/agent/shared/node_correction.go
Normal file
117
backend/services/agent/shared/node_correction.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package agentshared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
correctionHistoryKindKey = "newagent_history_kind"
|
||||
correctionHistoryKindCorrectionUser = "llm_correction_prompt"
|
||||
)
|
||||
|
||||
// AppendLLMCorrection 追加 LLM 修正提示到对话历史。
|
||||
//
|
||||
// 设计目的:
|
||||
// 1. 当 LLM 输出不符合预期(如不支持的 action、格式错误等),不应直接报错终止;
|
||||
// 2. 应该给 LLM 一个自我修正的机会,把错误反馈写回历史,让它重新生成;
|
||||
// 3. 该函数封装了“追加 assistant 消息 + 追加纠正提示”的通用流程。
|
||||
//
|
||||
// 参数说明:
|
||||
// - conversationContext: 对话上下文,用于追加历史消息;
|
||||
// - llmOutput: LLM 的原始输出内容,会作为 assistant 消息追加;
|
||||
// - validOptionsDesc: 合法选项的描述,用于构造纠正提示。
|
||||
func AppendLLMCorrection(
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
llmOutput string,
|
||||
validOptionsDesc string,
|
||||
) {
|
||||
if conversationContext == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assistantContent := strings.TrimSpace(llmOutput)
|
||||
appendCorrectionAssistantIfNeeded(conversationContext, assistantContent)
|
||||
|
||||
correctionContent := fmt.Sprintf(
|
||||
"你的输出不符合预期。%s 请重新分析当前状态,输出正确的内容。",
|
||||
validOptionsDesc,
|
||||
)
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.User,
|
||||
Content: correctionContent,
|
||||
Extra: map[string]any{
|
||||
correctionHistoryKindKey: correctionHistoryKindCorrectionUser,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// AppendLLMCorrectionWithHint 追加 LLM 修正提示(带自定义错误描述)。
|
||||
//
|
||||
// 相比 AppendLLMCorrection,该函数允许调用方提供更详细的错误描述,
|
||||
// 适用于需要明确告知 LLM 具体哪里出错的场景。
|
||||
func AppendLLMCorrectionWithHint(
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
llmOutput string,
|
||||
errorDesc string,
|
||||
validOptionsDesc string,
|
||||
) {
|
||||
if conversationContext == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assistantContent := strings.TrimSpace(llmOutput)
|
||||
appendCorrectionAssistantIfNeeded(conversationContext, assistantContent)
|
||||
|
||||
correctionContent := fmt.Sprintf(
|
||||
"%s %s 请重新分析当前状态,输出正确的内容。",
|
||||
errorDesc,
|
||||
validOptionsDesc,
|
||||
)
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.User,
|
||||
Content: correctionContent,
|
||||
Extra: map[string]any{
|
||||
correctionHistoryKindKey: correctionHistoryKindCorrectionUser,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// appendCorrectionAssistantIfNeeded 在纠错回灌前做最小降噪。
|
||||
//
|
||||
// 1. 空文本直接跳过,避免写入“占位噪音”;
|
||||
// 2. 若与“最近一条 assistant 文本”完全一致则跳过,避免同句反复回灌;
|
||||
// 3. 仅负责“是否回灌”判定,不负责生成纠错 user 提示。
|
||||
func appendCorrectionAssistantIfNeeded(
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
assistantContent string,
|
||||
) {
|
||||
if conversationContext == nil {
|
||||
return
|
||||
}
|
||||
assistantContent = strings.TrimSpace(assistantContent)
|
||||
if assistantContent == "" {
|
||||
return
|
||||
}
|
||||
|
||||
history := conversationContext.HistorySnapshot()
|
||||
for i := len(history) - 1; i >= 0; i-- {
|
||||
msg := history[i]
|
||||
if msg == nil || msg.Role != schema.Assistant {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(msg.Content) == assistantContent {
|
||||
return
|
||||
}
|
||||
// 只看最近一条 assistant,避免误去重很久以前的正常重复表达。
|
||||
break
|
||||
}
|
||||
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: assistantContent,
|
||||
})
|
||||
}
|
||||
121
backend/services/agent/shared/node_llm_debug.go
Normal file
121
backend/services/agent/shared/node_llm_debug.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package agentshared
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// LogNodeLLMContext 将某个节点即将送入 LLM 的完整消息上下文按统一格式打印到日志。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 统一输出 stage / phase / chat / round,方便按一次请求内的多次 LLM 调用串联排查;
|
||||
// 2. 完整展开 messages,不做截断,保证问题复现时能直接对照 prompt 组装结果;
|
||||
// 3. 该函数只负责调试日志,不参与任何业务判断,也不修改上下文内容。
|
||||
func LogNodeLLMContext(
|
||||
stage string,
|
||||
phase string,
|
||||
flowState *agentmodel.CommonState,
|
||||
messages []*schema.Message,
|
||||
) {
|
||||
chatID := ""
|
||||
roundUsed := 0
|
||||
if flowState != nil {
|
||||
chatID = flowState.ConversationID
|
||||
roundUsed = flowState.RoundUsed
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"[DEBUG] %s LLM context begin phase=%s chat=%s round=%d message_count=%d\n%s\n[DEBUG] %s LLM context end phase=%s chat=%s round=%d",
|
||||
stage,
|
||||
strings.TrimSpace(phase),
|
||||
chatID,
|
||||
roundUsed,
|
||||
len(messages),
|
||||
formatLLMMessagesForDebug(messages),
|
||||
stage,
|
||||
strings.TrimSpace(phase),
|
||||
chatID,
|
||||
roundUsed,
|
||||
)
|
||||
}
|
||||
|
||||
// formatLLMMessagesForDebug 将本轮送入 LLM 的完整消息上下文展开成可读多行日志。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 按消息索引逐条输出,便于和上游上下文构造步骤逐项对齐;
|
||||
// 2. 完整输出 content / reasoning_content / tool_calls / extra,不做截断;
|
||||
// 3. 仅用于调试打点,不参与业务决策。
|
||||
func formatLLMMessagesForDebug(messages []*schema.Message) string {
|
||||
if len(messages) == 0 {
|
||||
return "(empty messages)"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for i, msg := range messages {
|
||||
sb.WriteString(fmt.Sprintf("----- message[%d] -----\n", i))
|
||||
if msg == nil {
|
||||
sb.WriteString("role: <nil>\n\n")
|
||||
continue
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("role: %s\n", msg.Role))
|
||||
|
||||
if strings.TrimSpace(msg.ToolCallID) != "" {
|
||||
sb.WriteString(fmt.Sprintf("tool_call_id: %s\n", msg.ToolCallID))
|
||||
}
|
||||
if strings.TrimSpace(msg.ToolName) != "" {
|
||||
sb.WriteString(fmt.Sprintf("tool_name: %s\n", msg.ToolName))
|
||||
}
|
||||
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
sb.WriteString("tool_calls:\n")
|
||||
for j, call := range msg.ToolCalls {
|
||||
sb.WriteString(fmt.Sprintf(" - [%d] id=%s type=%s function=%s\n", j, call.ID, call.Type, call.Function.Name))
|
||||
sb.WriteString(" arguments:\n")
|
||||
sb.WriteString(indentMultilineForDebug(call.Function.Arguments, " "))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(msg.ReasoningContent) != "" {
|
||||
sb.WriteString("reasoning_content:\n")
|
||||
sb.WriteString(indentMultilineForDebug(msg.ReasoningContent, " "))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("content:\n")
|
||||
sb.WriteString(indentMultilineForDebug(msg.Content, " "))
|
||||
sb.WriteString("\n")
|
||||
|
||||
if len(msg.Extra) > 0 {
|
||||
sb.WriteString("extra:\n")
|
||||
raw, err := json.MarshalIndent(msg.Extra, "", " ")
|
||||
if err != nil {
|
||||
sb.WriteString(indentMultilineForDebug("<marshal_error>", " "))
|
||||
} else {
|
||||
sb.WriteString(indentMultilineForDebug(string(raw), " "))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// indentMultilineForDebug 为多行文本统一添加前缀缩进,避免日志折行后难以阅读。
|
||||
func indentMultilineForDebug(text, prefix string) string {
|
||||
if text == "" {
|
||||
return prefix + "<empty>"
|
||||
}
|
||||
lines := strings.Split(text, "\n")
|
||||
for i := range lines {
|
||||
lines[i] = prefix + lines[i]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
10
backend/services/agent/shared/node_thinking.go
Normal file
10
backend/services/agent/shared/node_thinking.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package agentshared
|
||||
|
||||
import llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
|
||||
func ResolveThinkingMode(enabled bool) llmservice.ThinkingMode {
|
||||
if enabled {
|
||||
return llmservice.ThinkingModeEnabled
|
||||
}
|
||||
return llmservice.ThinkingModeDisabled
|
||||
}
|
||||
290
backend/services/agent/shared/node_unified_compact.go
Normal file
290
backend/services/agent/shared/node_unified_compact.go
Normal file
@@ -0,0 +1,290 @@
|
||||
package agentshared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/pkg"
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentprompt "github.com/LoveLosita/smartflow/backend/services/agent/prompt"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// UnifiedCompactInput 是统一压缩入口的参数。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 从各节点输入中提取压缩所需的公共字段,消除对具体节点实现的直接依赖;
|
||||
// 2. 各节点(Plan/Chat/Deliver/Execute)构造此参数时,只需填充自己已有的运行时能力;
|
||||
// 3. StageName 和 StatusBlockID 用于区分日志来源与 SSE 状态推送目标。
|
||||
type UnifiedCompactInput struct {
|
||||
// Client 用于调用 LLM 压缩 msg1/msg2。
|
||||
Client *llmservice.Client
|
||||
// CompactionStore 用于持久化压缩摘要和 token 统计,为 nil 时跳过持久化。
|
||||
CompactionStore agentmodel.CompactionStore
|
||||
// FlowState 提供 userID / conversationID / roundUsed 等定位信息。
|
||||
FlowState *agentmodel.CommonState
|
||||
// Emitter 用于推送压缩进度 SSE 事件。
|
||||
Emitter *agentstream.ChunkEmitter
|
||||
// StageName 标识当前阶段,如 execute / plan / chat / deliver。
|
||||
StageName string
|
||||
// StatusBlockID 是 SSE 状态推送的 block ID,各节点使用自己的 block ID。
|
||||
StatusBlockID string
|
||||
}
|
||||
|
||||
// CompactUnifiedMessagesIfNeeded 检查统一消息结构的 token 预算,
|
||||
// 超限时对 msg1(历史对话)和 msg2(阶段工作区)执行 LLM 压缩。
|
||||
//
|
||||
// 消息布局约定(由统一消息构造器返回):
|
||||
// [0] system - msg0: 系统规则 + 工具简表
|
||||
// [1] assistant - msg1: 历史对话上下文
|
||||
// [2] assistant - msg2: 阶段工作区(Execute=ReAct Loop,其余通常为“暂无”)
|
||||
// [3] system - msg3: 阶段状态 + 记忆 + 指令
|
||||
//
|
||||
// 压缩策略:
|
||||
// 1. msg1 超过可用预算一半时触发 LLM 压缩(合并已有摘要 + 新内容);
|
||||
// 2. msg1 压缩后仍超限,则对 msg2 也做 LLM 压缩;
|
||||
// 3. 压缩结果持久化到 CompactionStore,下一轮可复用摘要避免重复计算。
|
||||
func CompactUnifiedMessagesIfNeeded(
|
||||
ctx context.Context,
|
||||
messages []*schema.Message,
|
||||
input UnifiedCompactInput,
|
||||
) []*schema.Message {
|
||||
if input.FlowState == nil {
|
||||
log.Printf("[COMPACT:%s] FlowState is nil, skip token stats refresh", input.StageName)
|
||||
return messages
|
||||
}
|
||||
|
||||
// 1. 非严格 4 段式时,退化成按角色汇总的统计,确保 context_token_stats 仍能刷新。
|
||||
if len(messages) != 4 {
|
||||
breakdown := estimateFallbackStageTokenBreakdown(messages)
|
||||
log.Printf(
|
||||
"[COMPACT:%s] fallback token stats refresh: total=%d budget=%d count=%d (msg0=%d msg1=%d msg2=%d msg3=%d)",
|
||||
input.StageName, breakdown.Total, breakdown.Budget, len(messages),
|
||||
breakdown.Msg0, breakdown.Msg1, breakdown.Msg2, breakdown.Msg3,
|
||||
)
|
||||
saveUnifiedTokenStats(ctx, input, breakdown)
|
||||
return messages
|
||||
}
|
||||
|
||||
// 2. 提取四条消息的文本内容,供预算检查与后续压缩使用。
|
||||
msg0 := messages[0].Content
|
||||
msg1 := messages[1].Content
|
||||
msg2 := messages[2].Content
|
||||
msg3 := messages[3].Content
|
||||
|
||||
// 3. 执行 token 预算检查,判断是否需要压缩历史对话或阶段工作区。
|
||||
breakdown, overBudget, needCompactMsg1, needCompactMsg2 := pkg.CheckStageTokenBudget(msg0, msg1, msg2, msg3)
|
||||
|
||||
log.Printf(
|
||||
"[COMPACT:%s] token budget check: total=%d budget=%d over=%v compactMsg1=%v compactMsg2=%v (msg0=%d msg1=%d msg2=%d msg3=%d)",
|
||||
input.StageName, breakdown.Total, breakdown.Budget, overBudget, needCompactMsg1, needCompactMsg2,
|
||||
breakdown.Msg0, breakdown.Msg1, breakdown.Msg2, breakdown.Msg3,
|
||||
)
|
||||
|
||||
if !overBudget {
|
||||
// 4. 未超限时仅记录 token 分布,不做压缩。
|
||||
saveUnifiedTokenStats(ctx, input, breakdown)
|
||||
return messages
|
||||
}
|
||||
|
||||
// 5. 先压缩 msg1(历史对话),它通常是最主要的 token 消耗来源。
|
||||
if needCompactMsg1 {
|
||||
msg1 = compactUnifiedMsg1(ctx, input, msg1)
|
||||
messages[1].Content = msg1
|
||||
breakdown = pkg.EstimateStageMessagesTokens(msg0, msg1, msg2, msg3)
|
||||
}
|
||||
|
||||
// 6. 若 msg1 压缩后仍超限,再压缩 msg2(阶段工作区 / ReAct 记录)。
|
||||
if needCompactMsg2 || breakdown.Total > pkg.StageTokenBudget {
|
||||
msg2 = compactUnifiedMsg2(ctx, input, msg2)
|
||||
messages[2].Content = msg2
|
||||
breakdown = pkg.EstimateStageMessagesTokens(msg0, msg1, msg2, msg3)
|
||||
}
|
||||
|
||||
// 7. 记录最终 token 分布,供后续调试与监控使用。
|
||||
saveUnifiedTokenStats(ctx, input, breakdown)
|
||||
|
||||
log.Printf(
|
||||
"[COMPACT:%s] after compaction: total=%d budget=%d (msg0=%d msg1=%d msg2=%d msg3=%d)",
|
||||
input.StageName, breakdown.Total, breakdown.Budget,
|
||||
breakdown.Msg0, breakdown.Msg1, breakdown.Msg2, breakdown.Msg3,
|
||||
)
|
||||
return messages
|
||||
}
|
||||
|
||||
// estimateFallbackStageTokenBreakdown 在非统一 4 段式场景下按消息角色做近似统计。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 先按消息类型汇总 token,保证总量准确;
|
||||
// 2. 再把最后一个 user 消息尽量视作 msg3,保留阶段指令语义;
|
||||
// 3. 其他历史内容归入 msg1 / msg2,确保上下文统计不会因为结构不标准而断更。
|
||||
func estimateFallbackStageTokenBreakdown(messages []*schema.Message) pkg.StageTokenBreakdown {
|
||||
breakdown := pkg.StageTokenBreakdown{Budget: pkg.StageTokenBudget}
|
||||
if len(messages) == 0 {
|
||||
return breakdown
|
||||
}
|
||||
|
||||
lastUserIndex := -1
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
msg := messages[i]
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
if msg.Role == schema.User {
|
||||
lastUserIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for i, msg := range messages {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
tokens := pkg.EstimateMessageTokens(msg)
|
||||
breakdown.Total += tokens
|
||||
|
||||
switch msg.Role {
|
||||
case schema.System:
|
||||
breakdown.Msg0 += tokens
|
||||
case schema.User:
|
||||
if i == lastUserIndex {
|
||||
breakdown.Msg3 += tokens
|
||||
} else {
|
||||
breakdown.Msg1 += tokens
|
||||
}
|
||||
case schema.Tool:
|
||||
breakdown.Msg2 += tokens
|
||||
case schema.Assistant:
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
breakdown.Msg2 += tokens
|
||||
} else {
|
||||
breakdown.Msg1 += tokens
|
||||
}
|
||||
default:
|
||||
breakdown.Msg1 += tokens
|
||||
}
|
||||
}
|
||||
|
||||
return breakdown
|
||||
}
|
||||
|
||||
// compactUnifiedMsg1 对 msg1(历史对话)执行 LLM 压缩。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. CompactionStore 为 nil 时跳过(测试环境 / 骨架期);
|
||||
// 2. 先加载该阶段已有的压缩摘要,与当前 msg1 合并后调 LLM 压缩;
|
||||
// 3. 压缩失败时降级为原始文本,不中断主流程;
|
||||
// 4. 压缩成功后持久化新摘要,供下一轮复用。
|
||||
func compactUnifiedMsg1(
|
||||
ctx context.Context,
|
||||
input UnifiedCompactInput,
|
||||
msg1 string,
|
||||
) string {
|
||||
if input.CompactionStore == nil {
|
||||
log.Printf("[COMPACT:%s] CompactionStore is nil, skip msg1 compaction", input.StageName)
|
||||
return msg1
|
||||
}
|
||||
|
||||
existingSummary, _, err := input.CompactionStore.LoadStageCompaction(ctx, input.FlowState.UserID, input.FlowState.ConversationID, input.StageName)
|
||||
if err != nil {
|
||||
log.Printf("[COMPACT:%s] load existing compaction failed: %v, proceed without cache", input.StageName, err)
|
||||
}
|
||||
|
||||
tokenBefore := pkg.EstimateTextTokens(msg1)
|
||||
_ = input.Emitter.EmitStatus(
|
||||
input.StatusBlockID, input.StageName, "context_compact_start",
|
||||
fmt.Sprintf("正在压缩对话历史(%d tokens)...", tokenBefore),
|
||||
false,
|
||||
)
|
||||
|
||||
newSummary, err := agentprompt.CompactMsg1(ctx, input.Client, msg1, existingSummary)
|
||||
if err != nil {
|
||||
log.Printf("[COMPACT:%s] compact msg1 failed: %v", input.StageName, err)
|
||||
_ = input.Emitter.EmitStatus(
|
||||
input.StatusBlockID, input.StageName, "context_compact_done",
|
||||
"对话历史压缩失败,使用原始文本",
|
||||
false,
|
||||
)
|
||||
return msg1
|
||||
}
|
||||
|
||||
tokenAfter := pkg.EstimateTextTokens(newSummary)
|
||||
_ = input.Emitter.EmitStatus(
|
||||
input.StatusBlockID, input.StageName, "context_compact_done",
|
||||
fmt.Sprintf("对话历史已压缩:%d → %d tokens", tokenBefore, tokenAfter),
|
||||
false,
|
||||
)
|
||||
|
||||
if err := input.CompactionStore.SaveStageCompaction(ctx, input.FlowState.UserID, input.FlowState.ConversationID, input.StageName, newSummary, input.FlowState.RoundUsed); err != nil {
|
||||
log.Printf("[COMPACT:%s] save compaction failed: %v", input.StageName, err)
|
||||
}
|
||||
|
||||
return newSummary
|
||||
}
|
||||
|
||||
// compactUnifiedMsg2 对 msg2(阶段工作区)执行 LLM 压缩。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 非 Execute 阶段的 msg2 通常内容较少,压缩即使收益有限也不应出错;
|
||||
// 2. Execute 阶段的 msg2 包含 ReAct loop 记录,压缩可显著节省 token;
|
||||
// 3. 压缩失败时降级为原始文本,不中断主流程。
|
||||
func compactUnifiedMsg2(
|
||||
ctx context.Context,
|
||||
input UnifiedCompactInput,
|
||||
msg2 string,
|
||||
) string {
|
||||
tokenBefore := pkg.EstimateTextTokens(msg2)
|
||||
_ = input.Emitter.EmitStatus(
|
||||
input.StatusBlockID, input.StageName, "context_compact_start",
|
||||
fmt.Sprintf("正在压缩执行记录(%d tokens)...", tokenBefore),
|
||||
false,
|
||||
)
|
||||
|
||||
compressed, err := agentprompt.CompactMsg2(ctx, input.Client, msg2)
|
||||
if err != nil {
|
||||
log.Printf("[COMPACT:%s] compact msg2 failed: %v", input.StageName, err)
|
||||
_ = input.Emitter.EmitStatus(
|
||||
input.StatusBlockID, input.StageName, "context_compact_done",
|
||||
"执行记录压缩失败,使用原始文本",
|
||||
false,
|
||||
)
|
||||
return msg2
|
||||
}
|
||||
|
||||
tokenAfter := pkg.EstimateTextTokens(compressed)
|
||||
_ = input.Emitter.EmitStatus(
|
||||
input.StatusBlockID, input.StageName, "context_compact_done",
|
||||
fmt.Sprintf("执行记录已压缩:%d → %d tokens", tokenBefore, tokenAfter),
|
||||
false,
|
||||
)
|
||||
|
||||
return compressed
|
||||
}
|
||||
|
||||
// saveUnifiedTokenStats 持久化当前 token 分布到存储层。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. CompactionStore 为 nil 时跳过(测试环境 / 骨架期);
|
||||
// 2. 序列化失败只记日志,不中断主流程;
|
||||
// 3. 写入失败只记日志,不中断主流程。
|
||||
func saveUnifiedTokenStats(
|
||||
ctx context.Context,
|
||||
input UnifiedCompactInput,
|
||||
breakdown pkg.StageTokenBreakdown,
|
||||
) {
|
||||
if input.CompactionStore == nil || input.FlowState == nil {
|
||||
return
|
||||
}
|
||||
statsJSON, err := json.Marshal(breakdown)
|
||||
if err != nil {
|
||||
log.Printf("[COMPACT:%s] marshal token stats failed: %v", input.StageName, err)
|
||||
return
|
||||
}
|
||||
if err := input.CompactionStore.SaveContextTokenStats(ctx, input.FlowState.UserID, input.FlowState.ConversationID, string(statsJSON)); err != nil {
|
||||
log.Printf("[COMPACT:%s] save token stats failed: %v", input.StageName, err)
|
||||
}
|
||||
}
|
||||
37
backend/services/agent/shared/node_visible_message.go
Normal file
37
backend/services/agent/shared/node_visible_message.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package agentshared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// PersistVisibleAssistantMessage 负责把“真正要展示给用户”的 assistant 文本交给 service 层持久化。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只处理可见的 assistant 消息,不处理内部纠错提示、工具调用结果和纯状态文案;
|
||||
// 2. 持久化失败只记日志,不反向中断节点主流程,避免“已经对外输出但后端补写失败”时把用户请求打断;
|
||||
// 3. 具体的 Redis / MySQL / 乐观缓存写入由 service 回调统一完成。
|
||||
func PersistVisibleAssistantMessage(
|
||||
ctx context.Context,
|
||||
persist agentmodel.PersistVisibleMessageFunc,
|
||||
state *agentmodel.CommonState,
|
||||
msg *schema.Message,
|
||||
) {
|
||||
if persist == nil || state == nil || msg == nil {
|
||||
return
|
||||
}
|
||||
|
||||
role := strings.TrimSpace(string(msg.Role))
|
||||
content := strings.TrimSpace(msg.Content)
|
||||
if role != string(schema.Assistant) || content == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if err := persist(ctx, state, msg); err != nil {
|
||||
log.Printf("[WARN] persist visible assistant message failed chat=%s phase=%s err=%v", state.ConversationID, state.Phase, err)
|
||||
}
|
||||
}
|
||||
85
backend/services/agent/shared/retry.go
Normal file
85
backend/services/agent/shared/retry.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package agentshared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RetryOptions 描述公共重试策略。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只定义“是否重试、最多几次、间隔多久”;
|
||||
// 2. 不关心具体业务是工具调用失败、模型 JSON 失败还是 DB 暂时不可用;
|
||||
// 3. 真正的业务兜底文案仍应由上层 node 决定。
|
||||
type RetryOptions struct {
|
||||
MaxAttempts int
|
||||
Interval time.Duration
|
||||
ShouldRetry func(err error) bool
|
||||
OnRetry func(attempt int, err error)
|
||||
}
|
||||
|
||||
// Do 执行一个只返回 error 的重试任务。
|
||||
//
|
||||
// 执行规则:
|
||||
// 1. 第一次执行也算一次 attempt;
|
||||
// 2. 任意一次成功即立即返回;
|
||||
// 3. 上下文取消、达到最大次数、或 ShouldRetry=false 时立即停止。
|
||||
func Do(ctx context.Context, options RetryOptions, fn func(attempt int) error) error {
|
||||
_, err := DoValue[struct{}](ctx, options, func(attempt int) (struct{}, error) {
|
||||
return struct{}{}, fn(attempt)
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// DoValue 执行一个带返回值的通用重试任务。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 旧 agent 里后续很多地方都会出现“失败重试 2~3 次”的模式;
|
||||
// 2. 这里先把循环骨架统一,避免每个 skill 自己写 for + sleep + ctx.Done;
|
||||
// 3. 上层只需关心“本轮失败要不要继续”,而不是重复造轮子。
|
||||
func DoValue[T any](ctx context.Context, options RetryOptions, fn func(attempt int) (T, error)) (T, error) {
|
||||
var zero T
|
||||
|
||||
maxAttempts := options.MaxAttempts
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = 1
|
||||
}
|
||||
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
|
||||
value, err := fn(attempt)
|
||||
if err == nil {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// 1. 到最后一次了,直接返回原错误,避免无意义等待。
|
||||
if attempt >= maxAttempts {
|
||||
return zero, err
|
||||
}
|
||||
// 2. 业务显式声明“不值得重试”时,立刻停止。
|
||||
if options.ShouldRetry != nil && !options.ShouldRetry(err) {
|
||||
return zero, err
|
||||
}
|
||||
// 3. 把重试钩子留给上层,用于打点或阶段提示。
|
||||
if options.OnRetry != nil {
|
||||
options.OnRetry(attempt, err)
|
||||
}
|
||||
// 4. 没有配置间隔则马上下一轮;配置了则等待,同时尊重 ctx 取消。
|
||||
if options.Interval <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
timer := time.NewTimer(options.Interval)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return zero, ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
|
||||
return zero, nil
|
||||
}
|
||||
35
backend/services/agent/shared/task_priority.go
Normal file
35
backend/services/agent/shared/task_priority.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package agentshared
|
||||
|
||||
const (
|
||||
TaskPriorityImportantUrgent = 1
|
||||
TaskPriorityImportantNotUrgent = 2
|
||||
TaskPrioritySimpleNotImportant = 3
|
||||
TaskPriorityComplexNotImportant = 4
|
||||
)
|
||||
|
||||
// QuickNote 优先级别名,保持与旧 agent/model 命名兼容。
|
||||
const (
|
||||
QuickNotePriorityImportantUrgent = TaskPriorityImportantUrgent
|
||||
QuickNotePriorityImportantNotUrgent = TaskPriorityImportantNotUrgent
|
||||
QuickNotePrioritySimpleNotImportant = TaskPrioritySimpleNotImportant
|
||||
QuickNotePriorityComplexNotImportant = TaskPriorityComplexNotImportant
|
||||
)
|
||||
|
||||
func IsValidTaskPriority(priority int) bool {
|
||||
return priority >= TaskPriorityImportantUrgent && priority <= TaskPriorityComplexNotImportant
|
||||
}
|
||||
|
||||
func PriorityLabelCN(priority int) string {
|
||||
switch priority {
|
||||
case TaskPriorityImportantUrgent:
|
||||
return "重要且紧急"
|
||||
case TaskPriorityImportantNotUrgent:
|
||||
return "重要不紧急"
|
||||
case TaskPrioritySimpleNotImportant:
|
||||
return "简单不重要"
|
||||
case TaskPriorityComplexNotImportant:
|
||||
return "复杂不重要"
|
||||
default:
|
||||
return "未知优先级"
|
||||
}
|
||||
}
|
||||
49
backend/services/agent/shared/time.go
Normal file
49
backend/services/agent/shared/time.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package agentshared
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// MinuteLayout 是 Agent 内部统一的分钟级时间文本格式。
|
||||
//
|
||||
// 设计原因:
|
||||
// 1. agent 里大量场景只需要精确到分钟;
|
||||
// 2. 秒级精度会增加提示词噪声,也容易让“同一请求内的当前时间”出现抖动;
|
||||
// 3. 先统一成一份常量,后续 quicknote / schedule 都直接复用。
|
||||
MinuteLayout = "2006-01-02 15:04"
|
||||
)
|
||||
|
||||
var (
|
||||
shanghaiLocOnce sync.Once
|
||||
shanghaiLoc *time.Location
|
||||
)
|
||||
|
||||
// ShanghaiLocation 返回 Agent 内部统一使用的东八区时区。
|
||||
func ShanghaiLocation() *time.Location {
|
||||
shanghaiLocOnce.Do(func() {
|
||||
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
// 兜底使用固定东八区,避免极端环境下因为系统时区文件缺失导致整个链路失败。
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
shanghaiLoc = loc
|
||||
})
|
||||
return shanghaiLoc
|
||||
}
|
||||
|
||||
// NowToMinute 返回当前北京时间,并截断到分钟级。
|
||||
func NowToMinute() time.Time {
|
||||
return time.Now().In(ShanghaiLocation()).Truncate(time.Minute)
|
||||
}
|
||||
|
||||
// NormalizeToMinute 把任意时间统一到北京时间分钟粒度。
|
||||
func NormalizeToMinute(t time.Time) time.Time {
|
||||
return t.In(ShanghaiLocation()).Truncate(time.Minute)
|
||||
}
|
||||
|
||||
// FormatMinute 把时间格式化为统一分钟级文本。
|
||||
func FormatMinute(t time.Time) string {
|
||||
return NormalizeToMinute(t).Format(MinuteLayout)
|
||||
}
|
||||
870
backend/services/agent/stream/emitter.go
Normal file
870
backend/services/agent/stream/emitter.go
Normal file
@@ -0,0 +1,870 @@
|
||||
package agentstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
)
|
||||
|
||||
// PayloadEmitter 是真正向外层 SSE 管道写 chunk 的最小接口。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 这里刻意不用 chan/string 绑死实现;
|
||||
// 2. 上层既可以传"写 channel"的函数,也可以传"写 gin stream"的函数;
|
||||
// 3. 只要签名是 `func(string) error`,都能接进来。
|
||||
type PayloadEmitter func(payload string) error
|
||||
|
||||
// StageEmitter 是 graph/node 对"当前阶段"进行推送的兼容接口。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 旧调用侧仍然只关心 stage/detail 两段文本,因此这里先保留;
|
||||
// 2. 新的结构化事件能力会通过 ChunkEmitter 补齐,而不是继续扩展这个函数签名;
|
||||
// 3. 这样能兼顾当前兼容性和后续协议升级空间。
|
||||
type StageEmitter func(stage, detail string)
|
||||
|
||||
// PseudoStreamOptions 描述"整段文字伪流式输出"的切块与节奏配置。
|
||||
//
|
||||
// 字段语义:
|
||||
// 1. MinChunkRunes:达到该最小长度后,若命中标点/换行等边界,可提前切块;
|
||||
// 2. MaxChunkRunes:单块最大 rune 数,超过后强制切块,避免一次性发太长;
|
||||
// 3. ChunkInterval:块与块之间的等待时间;为 0 时表示只做切块,不做人为延迟。
|
||||
type PseudoStreamOptions struct {
|
||||
MinChunkRunes int
|
||||
MaxChunkRunes int
|
||||
ChunkInterval time.Duration
|
||||
}
|
||||
|
||||
const (
|
||||
defaultPseudoStreamMinChunkRunes = 8
|
||||
defaultPseudoStreamMaxChunkRunes = 24
|
||||
)
|
||||
|
||||
// DefaultPseudoStreamOptions 返回一份适合中文短句展示的默认伪流式配置。
|
||||
func DefaultPseudoStreamOptions() PseudoStreamOptions {
|
||||
return PseudoStreamOptions{
|
||||
MinChunkRunes: defaultPseudoStreamMinChunkRunes,
|
||||
MaxChunkRunes: defaultPseudoStreamMaxChunkRunes,
|
||||
}
|
||||
}
|
||||
|
||||
// ChunkEmitter 是 agent 统一的 SSE chunk 发射器。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把"正文 / 思考 / 工具事件 / 确认请求 / 中断提示"统一转换成 OpenAI 兼容 payload;
|
||||
// 2. 负责在必要时把结构化事件附带成 extra,同时给当前前端提供可读的降级文本;
|
||||
// 3. 不负责决定什么时候发什么,也不负责持久化状态。
|
||||
type ChunkEmitter struct {
|
||||
emit PayloadEmitter
|
||||
RequestID string
|
||||
ModelName string
|
||||
Created int64
|
||||
|
||||
// thinkingGateMu 是“正文门卫”的轻量保护。
|
||||
// 1. 它只保护 thinking_summary 是否还能发,不串行化全部 SSE;
|
||||
// 2. 正文一旦开始,对应 block 的门会被关闭,后续同 block 摘要直接丢弃;
|
||||
// 3. 这样既避免摘要 goroutine 在正文之后补发旧思考,又不误杀后续节点的新一轮思考。
|
||||
thinkingGateMu sync.Mutex
|
||||
thinkingClosedBlocks map[string]bool
|
||||
// reasoningSummaryFunc 用于把原始 reasoning 压成用户可见摘要。
|
||||
// 1. 该函数由 service 层注入,stream 包只负责调度,不负责选择模型;
|
||||
// 2. 未注入时模型 reasoning 只会被静默丢弃,不再回退成 raw reasoning_content;
|
||||
// 3. 正文一旦开始,ReasoningDigestor 和 ChunkEmitter 会同时关门,迟到结果不会再发给前端。
|
||||
reasoningSummaryFunc ReasoningSummaryFunc
|
||||
// extraEventHook 用于把关键结构化事件同步给上层做持久化。
|
||||
// 1. hook 失败不能影响 SSE 主链路;
|
||||
// 2. hook 只接收 extra 结构,避免 emitter 反向依赖业务层;
|
||||
// 3. 不注入时保持空实现,兼容旧调用路径。
|
||||
extraEventHook func(extra *OpenAIChunkExtra)
|
||||
}
|
||||
|
||||
// NoopPayloadEmitter 返回一个空实现,便于骨架期安全占位。
|
||||
func NoopPayloadEmitter() PayloadEmitter {
|
||||
return func(string) error { return nil }
|
||||
}
|
||||
|
||||
// NoopStageEmitter 返回一个空实现,避免 graph 在没有接前端时处处判空。
|
||||
func NoopStageEmitter() StageEmitter {
|
||||
return func(stage, detail string) {}
|
||||
}
|
||||
|
||||
// WrapStageEmitter 把可空函数包装成稳定的 StageEmitter。
|
||||
func WrapStageEmitter(fn func(stage, detail string)) StageEmitter {
|
||||
if fn == nil {
|
||||
return NoopStageEmitter()
|
||||
}
|
||||
return fn
|
||||
}
|
||||
|
||||
// NewChunkEmitter 创建统一 chunk 发射器。
|
||||
//
|
||||
// 兜底策略:
|
||||
// 1. emit 为空时回退到 Noop,避免骨架期到处判空;
|
||||
// 2. modelName 为空时回填 worker,保持 OpenAI 兼容字段稳定;
|
||||
// 3. created <= 0 时用当前时间兜底,避免上层还没决定时间戳就无法复用。
|
||||
func NewChunkEmitter(emit PayloadEmitter, requestID, modelName string, created int64) *ChunkEmitter {
|
||||
if emit == nil {
|
||||
emit = NoopPayloadEmitter()
|
||||
}
|
||||
|
||||
modelName = strings.TrimSpace(modelName)
|
||||
if modelName == "" {
|
||||
modelName = "worker"
|
||||
}
|
||||
if created <= 0 {
|
||||
created = time.Now().Unix()
|
||||
}
|
||||
|
||||
return &ChunkEmitter{
|
||||
emit: emit,
|
||||
RequestID: strings.TrimSpace(requestID),
|
||||
ModelName: modelName,
|
||||
Created: created,
|
||||
}
|
||||
}
|
||||
|
||||
// SetExtraEventHook 设置结构化事件回调。
|
||||
func (e *ChunkEmitter) SetExtraEventHook(hook func(extra *OpenAIChunkExtra)) {
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
e.extraEventHook = hook
|
||||
}
|
||||
|
||||
// SetReasoningSummaryFunc 设置 reasoning 摘要模型调用函数。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只保存函数引用,不立即调用模型;
|
||||
// 2. 摘要触发频率、单飞、正文闸门由 ReasoningDigestor 负责;
|
||||
// 3. 传 nil 表示关闭摘要能力,后续 reasoning chunk 会被静默丢弃。
|
||||
func (e *ChunkEmitter) SetReasoningSummaryFunc(fn ReasoningSummaryFunc) {
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
e.reasoningSummaryFunc = fn
|
||||
}
|
||||
|
||||
// NewReasoningDigestor 为当前 block 创建一个 reasoning 摘要器。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 若未注入摘要函数,返回 nil,调用方只需跳过 raw reasoning 推送;
|
||||
// 2. 摘要结果先经过 ChunkEmitter 的正文门卫,再走统一 extra/hook 链路;
|
||||
// 3. Digestor 自身仍负责单飞、水位线和正文开始后的 in-flight 结果丢弃。
|
||||
func (e *ChunkEmitter) NewReasoningDigestor(ctx context.Context, blockID, stage string) (*ReasoningDigestor, error) {
|
||||
if e == nil || e.reasoningSummaryFunc == nil {
|
||||
return nil, nil
|
||||
}
|
||||
e.openThinkingSummaryGate(blockID, stage)
|
||||
return NewReasoningDigestor(ReasoningDigestorOptions{
|
||||
SummaryFunc: e.reasoningSummaryFunc,
|
||||
SummarySink: func(summary StreamThinkingSummaryExtra) {
|
||||
_ = e.EmitThinkingSummary(blockID, stage, summary)
|
||||
},
|
||||
BaseContext: ctx,
|
||||
SummaryTimeout: 8 * time.Second,
|
||||
})
|
||||
}
|
||||
|
||||
// EmitReasoningText 输出一段 reasoning 文字,并附带 reasoning_text extra。
|
||||
func (e *ChunkEmitter) EmitReasoningText(blockID, stage, text string, includeRole bool) error {
|
||||
if e == nil || e.emit == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
payload, err := ToOpenAIReasoningChunkWithExtra(
|
||||
e.RequestID,
|
||||
e.ModelName,
|
||||
e.Created,
|
||||
text,
|
||||
includeRole,
|
||||
NewReasoningTextExtra(blockID, stage),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if payload == "" {
|
||||
return nil
|
||||
}
|
||||
return e.emit(payload)
|
||||
}
|
||||
|
||||
// EmitAssistantText 输出一段 assistant 正文,并附带 assistant_text extra。
|
||||
func (e *ChunkEmitter) EmitAssistantText(blockID, stage, text string, includeRole bool) error {
|
||||
if e == nil || e.emit == nil {
|
||||
return nil
|
||||
}
|
||||
//这里如果不删掉,换行符会被吞了,导致文字黏连
|
||||
/* text = strings.TrimSpace(text)*/
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
e.closeThinkingSummaryGate(blockID, stage)
|
||||
|
||||
payload, err := ToOpenAIAssistantChunkWithExtra(
|
||||
e.RequestID,
|
||||
e.ModelName,
|
||||
e.Created,
|
||||
text,
|
||||
includeRole,
|
||||
NewAssistantTextExtra(blockID, stage),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if payload == "" {
|
||||
return nil
|
||||
}
|
||||
return e.emit(payload)
|
||||
}
|
||||
|
||||
// EmitThinkingSummary 输出一次“流式思考摘要”事件。
|
||||
//
|
||||
// 协议约束:
|
||||
// 1. 该事件只走 extra.thinking_summary,不回写 delta.content / delta.reasoning_content;
|
||||
// 2. 仍复用现有 extra hook,让上层在不依赖 emitter 细节的前提下同步持久化;
|
||||
// 3. includeRole 不再需要,因为 thinking_summary 本身就是纯结构化事件。
|
||||
func (e *ChunkEmitter) EmitThinkingSummary(blockID, stage string, summary StreamThinkingSummaryExtra) error {
|
||||
if e == nil || e.emit == nil {
|
||||
return nil
|
||||
}
|
||||
if e.isThinkingSummaryGateClosed(blockID, stage) {
|
||||
return nil
|
||||
}
|
||||
return e.emitExtraOnly(NewThinkingSummaryExtra(blockID, stage, summary))
|
||||
}
|
||||
|
||||
func (e *ChunkEmitter) openThinkingSummaryGate(blockID, stage string) {
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
e.thinkingGateMu.Lock()
|
||||
if e.thinkingClosedBlocks != nil {
|
||||
delete(e.thinkingClosedBlocks, thinkingSummaryGateKey(blockID, stage))
|
||||
}
|
||||
e.thinkingGateMu.Unlock()
|
||||
}
|
||||
|
||||
func (e *ChunkEmitter) closeThinkingSummaryGate(blockID, stage string) {
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
e.thinkingGateMu.Lock()
|
||||
if e.thinkingClosedBlocks == nil {
|
||||
e.thinkingClosedBlocks = make(map[string]bool)
|
||||
}
|
||||
e.thinkingClosedBlocks[thinkingSummaryGateKey(blockID, stage)] = true
|
||||
e.thinkingGateMu.Unlock()
|
||||
}
|
||||
|
||||
func (e *ChunkEmitter) isThinkingSummaryGateClosed(blockID, stage string) bool {
|
||||
if e == nil {
|
||||
return true
|
||||
}
|
||||
e.thinkingGateMu.Lock()
|
||||
defer e.thinkingGateMu.Unlock()
|
||||
return e.thinkingClosedBlocks[thinkingSummaryGateKey(blockID, stage)]
|
||||
}
|
||||
|
||||
func thinkingSummaryGateKey(blockID, stage string) string {
|
||||
blockID = strings.TrimSpace(blockID)
|
||||
stage = strings.TrimSpace(stage)
|
||||
if blockID != "" {
|
||||
return blockID
|
||||
}
|
||||
if stage != "" {
|
||||
return stage
|
||||
}
|
||||
return "__default__"
|
||||
}
|
||||
|
||||
// EmitPseudoReasoningText 把整段 reasoning 文本按伪流式方式逐块推出。
|
||||
func (e *ChunkEmitter) EmitPseudoReasoningText(ctx context.Context, blockID, stage, text string, options PseudoStreamOptions) error {
|
||||
return e.emitPseudoText(
|
||||
ctx,
|
||||
text,
|
||||
options,
|
||||
func(chunk string, includeRole bool) error {
|
||||
return e.EmitReasoningText(blockID, stage, chunk, includeRole)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// EmitPseudoAssistantText 把整段 assistant 文本按伪流式方式逐块推出。
|
||||
func (e *ChunkEmitter) EmitPseudoAssistantText(ctx context.Context, blockID, stage, text string, options PseudoStreamOptions) error {
|
||||
return e.emitPseudoText(
|
||||
ctx,
|
||||
text,
|
||||
options,
|
||||
func(chunk string, includeRole bool) error {
|
||||
return e.EmitAssistantText(blockID, stage, chunk, includeRole)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// EmitStatus 输出一条阶段状态事件。
|
||||
//
|
||||
// 协议约束:
|
||||
// 1. 状态事件只通过 extra 传递,不再写入 reasoning_content;
|
||||
// 2. includeRole 保留是为了兼容旧签名,当前结构化事件路径不依赖 role。
|
||||
func (e *ChunkEmitter) EmitStatus(blockID, stage, code, summary string, includeRole bool) error {
|
||||
if e == nil || e.emit == nil {
|
||||
return nil
|
||||
}
|
||||
_ = includeRole
|
||||
return e.emitExtraOnly(NewStatusExtra(blockID, stage, code, summary))
|
||||
}
|
||||
|
||||
// EmitToolCallStart 输出一次工具调用开始事件。
|
||||
//
|
||||
// 协议约束:
|
||||
// 1. 工具调用开始事件只走 extra.tool,不回写 reasoning_content;
|
||||
// 2. includeRole 保留是为了兼容旧签名,当前结构化事件路径不依赖 role。
|
||||
func (e *ChunkEmitter) EmitToolCallStart(blockID, stage, toolName, summary, argumentsPreview string, includeRole bool) error {
|
||||
if e == nil || e.emit == nil {
|
||||
return nil
|
||||
}
|
||||
_ = includeRole
|
||||
return e.emitExtraOnly(NewToolCallExtra(blockID, stage, toolName, "start", summary, argumentsPreview))
|
||||
}
|
||||
|
||||
// EmitToolCallResult 输出一次工具调用结果事件。
|
||||
//
|
||||
// 协议约束:
|
||||
// 1. status 由调用方明确传入(如 done/blocked/failed);
|
||||
// 2. 结果事件只走 extra.tool,不回写 reasoning_content。
|
||||
func (e *ChunkEmitter) EmitToolCallResult(
|
||||
blockID string,
|
||||
stage string,
|
||||
toolName string,
|
||||
status string,
|
||||
summary string,
|
||||
argumentsPreview string,
|
||||
argumentView map[string]any,
|
||||
resultView map[string]any,
|
||||
includeRole bool,
|
||||
) error {
|
||||
if e == nil || e.emit == nil {
|
||||
return nil
|
||||
}
|
||||
_ = includeRole
|
||||
return e.emitExtraOnly(NewToolResultExtra(
|
||||
blockID,
|
||||
stage,
|
||||
toolName,
|
||||
status,
|
||||
summary,
|
||||
argumentsPreview,
|
||||
argumentView,
|
||||
resultView,
|
||||
))
|
||||
}
|
||||
|
||||
// emitExtraOnly 仅输出结构化 extra 事件,不附带 content/reasoning。
|
||||
func (e *ChunkEmitter) emitExtraOnly(extra *OpenAIChunkExtra) error {
|
||||
if e == nil || e.emit == nil {
|
||||
return nil
|
||||
}
|
||||
e.emitExtraEventHook(extra)
|
||||
payload, err := ToOpenAIStreamWithExtra(
|
||||
nil,
|
||||
e.RequestID,
|
||||
e.ModelName,
|
||||
e.Created,
|
||||
false,
|
||||
extra,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if payload == "" {
|
||||
return nil
|
||||
}
|
||||
return e.emit(payload)
|
||||
}
|
||||
|
||||
func (e *ChunkEmitter) emitExtraEventHook(extra *OpenAIChunkExtra) {
|
||||
if e == nil || e.extraEventHook == nil || extra == nil {
|
||||
return
|
||||
}
|
||||
e.extraEventHook(extra)
|
||||
}
|
||||
|
||||
// EmitConfirmRequest 输出一次待确认事件。
|
||||
//
|
||||
// 当前展示策略:
|
||||
// 1. 对旧前端,confirm 文案通过 assistant content 直接可见;
|
||||
// 2. 对新前端,extra.confirm 可直接驱动确认卡片或按钮;
|
||||
// 3. 默认使用伪流式,避免确认文案整块砸下来太生硬。
|
||||
func (e *ChunkEmitter) EmitConfirmRequest(ctx context.Context, blockID, stage, interactionID, title, summary string, options PseudoStreamOptions) error {
|
||||
if e == nil || e.emit == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
text := buildConfirmAssistantText(title, summary)
|
||||
extra := NewConfirmRequestExtra(blockID, stage, interactionID, title, summary)
|
||||
e.emitExtraEventHook(extra)
|
||||
if strings.TrimSpace(text) != "" {
|
||||
e.closeThinkingSummaryGate(blockID, stage)
|
||||
}
|
||||
return e.emitPseudoText(
|
||||
ctx,
|
||||
text,
|
||||
options,
|
||||
func(chunk string, includeRole bool) error {
|
||||
payload, err := ToOpenAIAssistantChunkWithExtra(
|
||||
e.RequestID,
|
||||
e.ModelName,
|
||||
e.Created,
|
||||
chunk,
|
||||
includeRole,
|
||||
extra,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if payload == "" {
|
||||
return nil
|
||||
}
|
||||
return e.emit(payload)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// EmitInterruptMessage 输出一次中断提示。
|
||||
//
|
||||
// 适用场景:
|
||||
// 1. ask_user 追问;
|
||||
// 2. 告知用户当前会话已进入等待状态;
|
||||
// 3. 后续 connection_lost 恢复若需要对用户补一句解释,也可复用这一入口。
|
||||
func (e *ChunkEmitter) EmitInterruptMessage(ctx context.Context, blockID, stage, interactionID, interactionType, summary string, options PseudoStreamOptions) error {
|
||||
if e == nil || e.emit == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
text := buildInterruptAssistantText(interactionType, summary)
|
||||
extra := NewInterruptExtra(blockID, stage, interactionID, interactionType, summary)
|
||||
if strings.TrimSpace(text) != "" {
|
||||
e.closeThinkingSummaryGate(blockID, stage)
|
||||
}
|
||||
return e.emitPseudoText(
|
||||
ctx,
|
||||
text,
|
||||
options,
|
||||
func(chunk string, includeRole bool) error {
|
||||
payload, err := ToOpenAIAssistantChunkWithExtra(
|
||||
e.RequestID,
|
||||
e.ModelName,
|
||||
e.Created,
|
||||
chunk,
|
||||
includeRole,
|
||||
extra,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if payload == "" {
|
||||
return nil
|
||||
}
|
||||
return e.emit(payload)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// EmitScheduleCompleted 输出一次"排程完毕"卡片事件。
|
||||
//
|
||||
// 协议约束:
|
||||
// 1. 只走 extra,不附带 content/reasoning;
|
||||
// 2. 前端拿到 kind=schedule_completed 后自行拉取排程数据渲染卡片。
|
||||
func (e *ChunkEmitter) EmitScheduleCompleted(blockID, stage string) error {
|
||||
if e == nil || e.emit == nil {
|
||||
return nil
|
||||
}
|
||||
return e.emitExtraOnly(NewScheduleCompletedExtra(blockID, stage))
|
||||
}
|
||||
|
||||
// EmitBusinessCard 输出一次业务结果卡片事件。
|
||||
//
|
||||
// 协议约束:
|
||||
// 1. 只走 extra,不附带 content/reasoning;
|
||||
// 2. card 为空时直接跳过,避免发出缺少关键字段的空卡片。
|
||||
func (e *ChunkEmitter) EmitBusinessCard(blockID, stage string, card *StreamBusinessCardExtra) error {
|
||||
if e == nil || e.emit == nil || card == nil {
|
||||
return nil
|
||||
}
|
||||
return e.emitExtraOnly(NewBusinessCardExtra(blockID, stage, card))
|
||||
}
|
||||
|
||||
// EmitFinish 统一输出 stop 结束块,并带上 finish extra。
|
||||
func (e *ChunkEmitter) EmitFinish(blockID, stage string) error {
|
||||
if e == nil || e.emit == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
payload, err := ToOpenAIFinishStreamWithExtra(
|
||||
e.RequestID,
|
||||
e.ModelName,
|
||||
e.Created,
|
||||
NewFinishExtra(blockID, stage),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if payload == "" {
|
||||
return nil
|
||||
}
|
||||
return e.emit(payload)
|
||||
}
|
||||
|
||||
// EmitDone 统一输出 OpenAI 兼容流式结束标记。
|
||||
func (e *ChunkEmitter) EmitDone() error {
|
||||
if e == nil || e.emit == nil {
|
||||
return nil
|
||||
}
|
||||
return e.emit("[DONE]")
|
||||
}
|
||||
|
||||
// EmitStreamAssistantText 从 StreamReader 逐 chunk 读取并实时推送 assistant 正文。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把 StreamReader 的每个 chunk 实时转换为 SSE payload 推送;
|
||||
// 2. 负责累计完整文本并返回,供调用方写入 history;
|
||||
// 3. 不负责打开/关闭 StreamReader,调用方负责生命周期管理。
|
||||
func (e *ChunkEmitter) EmitStreamAssistantText(
|
||||
ctx context.Context,
|
||||
reader llmservice.StreamReader,
|
||||
blockID, stage string,
|
||||
) (string, error) {
|
||||
if e == nil || reader == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var fullText strings.Builder
|
||||
firstChunk := true
|
||||
digestor, digestorErr := e.NewReasoningDigestor(ctx, blockID, stage)
|
||||
if digestorErr != nil {
|
||||
return "", digestorErr
|
||||
}
|
||||
defer func() {
|
||||
if digestor != nil {
|
||||
_ = digestor.Close(ctx)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
chunk, err := reader.Recv()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return fullText.String(), err
|
||||
}
|
||||
|
||||
// 1. reasoning content 只喂给摘要器,不再透传给前端。
|
||||
// 2. 未注入摘要能力时直接丢弃,避免 raw reasoning_content 泄漏到 SSE。
|
||||
if chunk != nil && strings.TrimSpace(chunk.ReasoningContent) != "" {
|
||||
if digestor != nil {
|
||||
digestor.Append(chunk.ReasoningContent)
|
||||
}
|
||||
}
|
||||
|
||||
// 推送 assistant 正文。
|
||||
if chunk != nil && chunk.Content != "" {
|
||||
if digestor != nil {
|
||||
digestor.MarkContentStarted()
|
||||
}
|
||||
if emitErr := e.EmitAssistantText(blockID, stage, chunk.Content, firstChunk); emitErr != nil {
|
||||
return fullText.String(), emitErr
|
||||
}
|
||||
fullText.WriteString(chunk.Content)
|
||||
firstChunk = false
|
||||
}
|
||||
}
|
||||
|
||||
return fullText.String(), nil
|
||||
}
|
||||
|
||||
// EmitStreamReasoningText 从 StreamReader 逐 chunk 读取 reasoning,并转成低频 thinking_summary。
|
||||
//
|
||||
// 与 EmitStreamAssistantText 结构相同,但不再输出 raw ReasoningContent。
|
||||
// 用于只需展示思考过程而无需展示正文的场景。
|
||||
func (e *ChunkEmitter) EmitStreamReasoningText(
|
||||
ctx context.Context,
|
||||
reader llmservice.StreamReader,
|
||||
blockID, stage string,
|
||||
) (string, error) {
|
||||
if e == nil || reader == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var fullText strings.Builder
|
||||
digestor, digestorErr := e.NewReasoningDigestor(ctx, blockID, stage)
|
||||
if digestorErr != nil {
|
||||
return "", digestorErr
|
||||
}
|
||||
defer func() {
|
||||
if digestor != nil {
|
||||
_ = digestor.Close(ctx)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
chunk, err := reader.Recv()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return fullText.String(), err
|
||||
}
|
||||
|
||||
if chunk != nil && strings.TrimSpace(chunk.ReasoningContent) != "" {
|
||||
if digestor != nil {
|
||||
digestor.Append(chunk.ReasoningContent)
|
||||
}
|
||||
fullText.WriteString(chunk.ReasoningContent)
|
||||
}
|
||||
}
|
||||
|
||||
return fullText.String(), nil
|
||||
}
|
||||
|
||||
// EmitStageAsReasoning 把"阶段提示"伪装成 reasoning chunk 推给前端。
|
||||
//
|
||||
// 兼容说明:
|
||||
// 1. 保留旧函数签名,方便当前旧链路直接复用;
|
||||
// 2. 实际实现已升级为统一的 ChunkEmitter + status extra;
|
||||
// 3. 这样后续新链路可以直接跳过这个兼容函数,转用结构化方法。
|
||||
func EmitStageAsReasoning(emit PayloadEmitter, requestID, modelName string, created int64, stage, detail string, includeRole bool) error {
|
||||
return NewChunkEmitter(emit, requestID, modelName, created).EmitStatus(stage, stage, stage, detail, includeRole)
|
||||
}
|
||||
|
||||
// EmitAssistantReply 把一段完整正文作为 assistant chunk 推出。
|
||||
//
|
||||
// 注意:
|
||||
// 1. 这里保持"整段发",不主动切块;
|
||||
// 2. 若后续某条链路需要更自然的阅读节奏,应直接调用 EmitPseudoAssistantText;
|
||||
// 3. 为兼容老调用侧,这里 blockID 和 stage 都留空。
|
||||
func EmitAssistantReply(emit PayloadEmitter, requestID, modelName string, created int64, content string, includeRole bool) error {
|
||||
return NewChunkEmitter(emit, requestID, modelName, created).EmitAssistantText("", "", content, includeRole)
|
||||
}
|
||||
|
||||
// EmitFinish 统一输出 stop 结束块。
|
||||
func EmitFinish(emit PayloadEmitter, requestID, modelName string, created int64) error {
|
||||
return NewChunkEmitter(emit, requestID, modelName, created).EmitFinish("", "")
|
||||
}
|
||||
|
||||
// EmitDone 统一输出 OpenAI 兼容流式结束标记。
|
||||
func EmitDone(emit PayloadEmitter) error {
|
||||
return NewChunkEmitter(emit, "", "", 0).EmitDone()
|
||||
}
|
||||
|
||||
func buildStageReasoningText(stage, detail string) string {
|
||||
stage = strings.TrimSpace(stage)
|
||||
detail = strings.TrimSpace(detail)
|
||||
|
||||
switch {
|
||||
case stage != "" && detail != "":
|
||||
return fmt.Sprintf("阶段:%s\n%s", stage, detail)
|
||||
case stage != "":
|
||||
return fmt.Sprintf("阶段:%s", stage)
|
||||
default:
|
||||
return detail
|
||||
}
|
||||
}
|
||||
|
||||
func buildToolCallReasoningText(toolName, summary, argumentsPreview string) string {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
summary = strings.TrimSpace(summary)
|
||||
argumentsPreview = strings.TrimSpace(argumentsPreview)
|
||||
|
||||
lines := make([]string, 0, 3)
|
||||
if toolName != "" {
|
||||
lines = append(lines, fmt.Sprintf("正在调用工具:%s", toolName))
|
||||
}
|
||||
if summary != "" {
|
||||
lines = append(lines, summary)
|
||||
}
|
||||
if argumentsPreview != "" {
|
||||
lines = append(lines, fmt.Sprintf("参数摘要:%s", argumentsPreview))
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func buildToolResultReasoningText(toolName, summary string) string {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
summary = strings.TrimSpace(summary)
|
||||
|
||||
switch {
|
||||
case toolName != "" && summary != "":
|
||||
return fmt.Sprintf("工具结果:%s\n%s", toolName, summary)
|
||||
case toolName != "":
|
||||
return fmt.Sprintf("工具结果:%s", toolName)
|
||||
default:
|
||||
return summary
|
||||
}
|
||||
}
|
||||
|
||||
func buildConfirmAssistantText(title, summary string) string {
|
||||
title = strings.TrimSpace(title)
|
||||
summary = strings.TrimSpace(summary)
|
||||
|
||||
switch {
|
||||
case title != "" && summary != "":
|
||||
return fmt.Sprintf("%s\n%s", title, summary)
|
||||
case title != "":
|
||||
return title
|
||||
default:
|
||||
return summary
|
||||
}
|
||||
}
|
||||
|
||||
func buildInterruptAssistantText(interactionType, summary string) string {
|
||||
interactionType = strings.TrimSpace(interactionType)
|
||||
summary = strings.TrimSpace(summary)
|
||||
|
||||
switch {
|
||||
case interactionType != "" && summary != "":
|
||||
return fmt.Sprintf("当前进入 %s 阶段。\n%s", interactionType, summary)
|
||||
case summary != "":
|
||||
return summary
|
||||
default:
|
||||
return interactionType
|
||||
}
|
||||
}
|
||||
|
||||
func (e *ChunkEmitter) emitPseudoText(ctx context.Context, text string, options PseudoStreamOptions, emitChunk func(chunk string, includeRole bool) error) error {
|
||||
if emitChunk == nil {
|
||||
return nil
|
||||
}
|
||||
// 只剥首尾空格和制表符,保留结尾 \n,让上层加的段落分隔符能作为内容的一部分推出。
|
||||
text = strings.TrimRight(strings.TrimLeft(text, " \t\r\n"), " \t\r")
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
chunks := SplitPseudoStreamText(text, options)
|
||||
for i, chunk := range chunks {
|
||||
if err := emitChunk(chunk, i == 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if i < len(chunks)-1 {
|
||||
if err := waitPseudoStreamInterval(ctx, options.ChunkInterval); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SplitPseudoStreamText 按"标点优先、长度兜底"的策略切分整段文本。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 优先在句号、问号、感叹号、分号、换行等自然边界切块,保证阅读顺畅;
|
||||
// 2. 若长时间遇不到合适边界,则在 MaxChunkRunes 处强制切块,避免整段卡太久;
|
||||
// 3. 对中文文本优先按 rune 长度处理,避免多字节字符被截断。
|
||||
func SplitPseudoStreamText(text string, options PseudoStreamOptions) []string {
|
||||
hasTrailingNewline := strings.HasSuffix(strings.TrimRight(text, " \t"), "\n")
|
||||
text = strings.TrimRight(strings.TrimLeft(text, " \t\r\n"), " \t\r")
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
options = normalizePseudoStreamOptions(options)
|
||||
runes := []rune(text)
|
||||
if len(runes) <= options.MaxChunkRunes {
|
||||
// text 经 TrimRight(" \t\r") 已保留结尾 \n,直接返回,不再追加。
|
||||
return []string{text}
|
||||
}
|
||||
|
||||
chunks := make([]string, 0, len(runes)/options.MinChunkRunes+1)
|
||||
start := 0
|
||||
size := 0
|
||||
for i, r := range runes {
|
||||
size++
|
||||
|
||||
shouldFlush := false
|
||||
if size >= options.MaxChunkRunes {
|
||||
shouldFlush = true
|
||||
}
|
||||
if size >= options.MinChunkRunes && isPseudoStreamBoundary(r) {
|
||||
shouldFlush = true
|
||||
}
|
||||
if !shouldFlush {
|
||||
continue
|
||||
}
|
||||
|
||||
// 用 Trim(" \t\r") 代替 TrimSpace:保留 chunk 内的 \n(段落分隔符)。
|
||||
// TrimSpace 会把 flush 在 \n 边界时结尾的 \n、以及下一段开头的 \n 全部删掉,导致黏连。
|
||||
chunk := strings.Trim(string(runes[start:i+1]), " \t\r")
|
||||
if chunk != "" {
|
||||
chunks = append(chunks, chunk)
|
||||
}
|
||||
start = i + 1
|
||||
size = 0
|
||||
}
|
||||
|
||||
if start < len(runes) {
|
||||
chunk := strings.Trim(string(runes[start:]), " \t\r")
|
||||
if chunk != "" {
|
||||
chunks = append(chunks, chunk)
|
||||
}
|
||||
}
|
||||
|
||||
if len(chunks) == 0 {
|
||||
return []string{text}
|
||||
}
|
||||
// 仅当最后一个 chunk 尚未以 \n 结尾时才追加,避免 Trim 修复后出现双换行。
|
||||
if hasTrailingNewline && !strings.HasSuffix(chunks[len(chunks)-1], "\n") {
|
||||
chunks[len(chunks)-1] += "\n"
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
func normalizePseudoStreamOptions(options PseudoStreamOptions) PseudoStreamOptions {
|
||||
if options.MinChunkRunes <= 0 {
|
||||
options.MinChunkRunes = defaultPseudoStreamMinChunkRunes
|
||||
}
|
||||
if options.MaxChunkRunes <= 0 {
|
||||
options.MaxChunkRunes = defaultPseudoStreamMaxChunkRunes
|
||||
}
|
||||
if options.MaxChunkRunes < options.MinChunkRunes {
|
||||
options.MaxChunkRunes = options.MinChunkRunes
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func isPseudoStreamBoundary(r rune) bool {
|
||||
switch r {
|
||||
case '。', '!', '?', ';', ':', ',', '.', '!', '?', ';', ':', ',', '\n':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func waitPseudoStreamInterval(ctx context.Context, interval time.Duration) error {
|
||||
if interval <= 0 {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
timer := time.NewTimer(interval)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
404
backend/services/agent/stream/openai.go
Normal file
404
backend/services/agent/stream/openai.go
Normal file
@@ -0,0 +1,404 @@
|
||||
package agentstream
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// OpenAIChunkResponse 是 OpenAI 兼容的流式 chunk DTO。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 外层继续保持 OpenAI 兼容壳,避免前端和调试工具一次性大改;
|
||||
// 2. 新增顶层 Extra 字段,用来承载“工具调用 / 确认请求 / 中断恢复”等结构化事件;
|
||||
// 3. 这样旧前端仍可继续读取 delta.content / delta.reasoning_content,新前端则可渐进消费 extra。
|
||||
type OpenAIChunkResponse struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []OpenAIChunkChoice `json:"choices,omitempty"`
|
||||
Extra *OpenAIChunkExtra `json:"extra,omitempty"`
|
||||
}
|
||||
|
||||
// OpenAIChunkChoice 对应 OpenAI choices[0]。
|
||||
type OpenAIChunkChoice struct {
|
||||
Index int `json:"index"`
|
||||
Delta OpenAIChunkDelta `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
// OpenAIChunkDelta 是真正承载 role/content/reasoning 的位置。
|
||||
type OpenAIChunkDelta struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
}
|
||||
|
||||
// StreamExtraKind 表示当前 chunk 在业务语义上属于哪类事件。
|
||||
type StreamExtraKind string
|
||||
|
||||
const (
|
||||
StreamExtraKindReasoningText StreamExtraKind = "reasoning_text"
|
||||
StreamExtraKindThinkingSummary StreamExtraKind = "thinking_summary"
|
||||
StreamExtraKindAssistantText StreamExtraKind = "assistant_text"
|
||||
StreamExtraKindStatus StreamExtraKind = "status"
|
||||
StreamExtraKindToolCall StreamExtraKind = "tool_call"
|
||||
StreamExtraKindToolResult StreamExtraKind = "tool_result"
|
||||
StreamExtraKindConfirm StreamExtraKind = "confirm_request"
|
||||
StreamExtraKindInterrupt StreamExtraKind = "interrupt"
|
||||
StreamExtraKindBusinessCard StreamExtraKind = "business_card"
|
||||
StreamExtraKindFinish StreamExtraKind = "finish"
|
||||
StreamExtraKindScheduleCompleted StreamExtraKind = "schedule_completed"
|
||||
)
|
||||
|
||||
// StreamDisplayMode 表示前端更适合如何展示该结构化事件。
|
||||
type StreamDisplayMode string
|
||||
|
||||
const (
|
||||
StreamDisplayModeAppend StreamDisplayMode = "append"
|
||||
StreamDisplayModeReplace StreamDisplayMode = "replace"
|
||||
StreamDisplayModeCard StreamDisplayMode = "card"
|
||||
)
|
||||
|
||||
// OpenAIChunkExtra 是挂在 OpenAI 兼容壳上的结构化扩展字段。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. Kind / Stage / BlockID 提供前端排版和分组所需的最小元信息;
|
||||
// 2. Status / Tool / Confirm / Interrupt / BusinessCard 只存展示层真正需要的摘要,不直接耦合后端完整状态对象;
|
||||
// 3. Meta 留给后续做灰度扩展,避免每加一种小字段都要立刻改 DTO 结构。
|
||||
type OpenAIChunkExtra struct {
|
||||
Kind StreamExtraKind `json:"kind,omitempty"`
|
||||
BlockID string `json:"block_id,omitempty"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
DisplayMode StreamDisplayMode `json:"display_mode,omitempty"`
|
||||
ThinkingSummary *StreamThinkingSummaryExtra `json:"thinking_summary,omitempty"`
|
||||
Status *StreamStatusExtra `json:"status,omitempty"`
|
||||
Tool *StreamToolExtra `json:"tool,omitempty"`
|
||||
Confirm *StreamConfirmExtra `json:"confirm,omitempty"`
|
||||
Interrupt *StreamInterruptExtra `json:"interrupt,omitempty"`
|
||||
BusinessCard *StreamBusinessCardExtra `json:"business_card,omitempty"`
|
||||
Meta map[string]any `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// StreamThinkingSummaryExtra 表示“流式思考摘要”事件。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. short_summary 仅用于 SSE 端快速展示短句,不要求与持久化内容完全一致;
|
||||
// 2. detail_summary 作为更完整的摘要正文,后续持久化层可直接复用;
|
||||
// 3. summary_seq / final / duration_seconds 由摘要调度层补充运行态信息,前端可据此去重和排序。
|
||||
type StreamThinkingSummaryExtra struct {
|
||||
SummarySeq int `json:"summary_seq,omitempty"`
|
||||
ShortSummary string `json:"short_summary,omitempty"`
|
||||
DetailSummary string `json:"detail_summary,omitempty"`
|
||||
Final bool `json:"final,omitempty"`
|
||||
DurationSeconds float64 `json:"duration_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// StreamStatusExtra 表示普通阶段状态或提示性事件。
|
||||
type StreamStatusExtra struct {
|
||||
Code string `json:"code,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
// StreamToolExtra 表示一次工具调用相关事件。
|
||||
type StreamToolExtra struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
ArgumentsPreview string `json:"arguments_preview,omitempty"`
|
||||
ArgumentView map[string]any `json:"argument_view,omitempty"`
|
||||
ResultView map[string]any `json:"result_view,omitempty"`
|
||||
}
|
||||
|
||||
// StreamConfirmExtra 表示一次待确认事件的展示摘要。
|
||||
type StreamConfirmExtra struct {
|
||||
InteractionID string `json:"interaction_id,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
// StreamInterruptExtra 表示一次中断事件的展示摘要。
|
||||
type StreamInterruptExtra struct {
|
||||
InteractionID string `json:"interaction_id,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
// StreamBusinessCardExtra 表示一张业务结果卡片。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. CardType 只允许前端已约定的卡片类型(task_query/task_record);
|
||||
// 2. Source 仅在 task_record 时有语义,其他卡片类型可为空;
|
||||
// 3. Data 承载“可直接渲染的最小快照”,避免前端再二次补拉才能看到结果。
|
||||
type StreamBusinessCardExtra struct {
|
||||
CardType string `json:"card_type,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// ToOpenAIStream 把 Eino message 转成 OpenAI 兼容 chunk。
|
||||
func ToOpenAIStream(chunk *schema.Message, requestID, modelName string, created int64, includeRole bool) (string, error) {
|
||||
return ToOpenAIStreamWithExtra(chunk, requestID, modelName, created, includeRole, nil)
|
||||
}
|
||||
|
||||
// ToOpenAIStreamWithExtra 把 Eino message 转成带 extra 的 OpenAI 兼容 chunk。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把 chunk.Content / chunk.ReasoningContent 映射到协议字段;
|
||||
// 2. 负责挂载可选 extra,供前端识别工具调用、确认请求等结构化事件;
|
||||
// 3. 不负责发送,也不负责决定“这个 chunk 该不该推”。
|
||||
func ToOpenAIStreamWithExtra(chunk *schema.Message, requestID, modelName string, created int64, includeRole bool, extra *OpenAIChunkExtra) (string, error) {
|
||||
delta := OpenAIChunkDelta{}
|
||||
if includeRole {
|
||||
delta.Role = "assistant"
|
||||
}
|
||||
if chunk != nil {
|
||||
delta.Content = chunk.Content
|
||||
delta.ReasoningContent = chunk.ReasoningContent
|
||||
}
|
||||
return buildOpenAIChunkPayload(requestID, modelName, created, delta, nil, extra)
|
||||
}
|
||||
|
||||
// ToOpenAIReasoningChunk 直接构造一个 reasoning chunk。
|
||||
func ToOpenAIReasoningChunk(requestID, modelName string, created int64, reasoning string, includeRole bool) (string, error) {
|
||||
return ToOpenAIReasoningChunkWithExtra(requestID, modelName, created, reasoning, includeRole, nil)
|
||||
}
|
||||
|
||||
// ToOpenAIReasoningChunkWithExtra 直接构造一个带 extra 的 reasoning chunk。
|
||||
func ToOpenAIReasoningChunkWithExtra(requestID, modelName string, created int64, reasoning string, includeRole bool, extra *OpenAIChunkExtra) (string, error) {
|
||||
delta := OpenAIChunkDelta{ReasoningContent: reasoning}
|
||||
if includeRole {
|
||||
delta.Role = "assistant"
|
||||
}
|
||||
return buildOpenAIChunkPayload(requestID, modelName, created, delta, nil, extra)
|
||||
}
|
||||
|
||||
// ToOpenAIAssistantChunk 直接构造一个正文 chunk。
|
||||
func ToOpenAIAssistantChunk(requestID, modelName string, created int64, content string, includeRole bool) (string, error) {
|
||||
return ToOpenAIAssistantChunkWithExtra(requestID, modelName, created, content, includeRole, nil)
|
||||
}
|
||||
|
||||
// ToOpenAIAssistantChunkWithExtra 直接构造一个带 extra 的正文 chunk。
|
||||
func ToOpenAIAssistantChunkWithExtra(requestID, modelName string, created int64, content string, includeRole bool, extra *OpenAIChunkExtra) (string, error) {
|
||||
delta := OpenAIChunkDelta{Content: content}
|
||||
if includeRole {
|
||||
delta.Role = "assistant"
|
||||
}
|
||||
return buildOpenAIChunkPayload(requestID, modelName, created, delta, nil, extra)
|
||||
}
|
||||
|
||||
// ToOpenAIFinishStream 生成流式结束 chunk(finish_reason=stop)。
|
||||
func ToOpenAIFinishStream(requestID, modelName string, created int64) (string, error) {
|
||||
return ToOpenAIFinishStreamWithExtra(requestID, modelName, created, nil)
|
||||
}
|
||||
|
||||
// ToOpenAIFinishStreamWithExtra 生成带 extra 的流式结束 chunk。
|
||||
func ToOpenAIFinishStreamWithExtra(requestID, modelName string, created int64, extra *OpenAIChunkExtra) (string, error) {
|
||||
stop := "stop"
|
||||
return buildOpenAIChunkPayload(requestID, modelName, created, OpenAIChunkDelta{}, &stop, extra)
|
||||
}
|
||||
|
||||
// NewReasoningTextExtra 创建“思考文字”事件的 extra。
|
||||
func NewReasoningTextExtra(blockID, stage string) *OpenAIChunkExtra {
|
||||
return &OpenAIChunkExtra{
|
||||
Kind: StreamExtraKindReasoningText,
|
||||
BlockID: blockID,
|
||||
Stage: stage,
|
||||
DisplayMode: StreamDisplayModeAppend,
|
||||
}
|
||||
}
|
||||
|
||||
// NewThinkingSummaryExtra 创建“流式思考摘要”事件的 extra。
|
||||
func NewThinkingSummaryExtra(blockID, stage string, summary StreamThinkingSummaryExtra) *OpenAIChunkExtra {
|
||||
return &OpenAIChunkExtra{
|
||||
Kind: StreamExtraKindThinkingSummary,
|
||||
BlockID: blockID,
|
||||
Stage: stage,
|
||||
DisplayMode: StreamDisplayModeAppend,
|
||||
ThinkingSummary: &summary,
|
||||
}
|
||||
}
|
||||
|
||||
// NewAssistantTextExtra 创建“正文文字”事件的 extra。
|
||||
func NewAssistantTextExtra(blockID, stage string) *OpenAIChunkExtra {
|
||||
return &OpenAIChunkExtra{
|
||||
Kind: StreamExtraKindAssistantText,
|
||||
BlockID: blockID,
|
||||
Stage: stage,
|
||||
DisplayMode: StreamDisplayModeAppend,
|
||||
}
|
||||
}
|
||||
|
||||
// NewStatusExtra 创建普通状态事件的 extra。
|
||||
func NewStatusExtra(blockID, stage, code, summary string) *OpenAIChunkExtra {
|
||||
return &OpenAIChunkExtra{
|
||||
Kind: StreamExtraKindStatus,
|
||||
BlockID: blockID,
|
||||
Stage: stage,
|
||||
DisplayMode: StreamDisplayModeCard,
|
||||
Status: &StreamStatusExtra{
|
||||
Code: code,
|
||||
Summary: summary,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewToolCallExtra 创建“工具调用开始/中间态”事件的 extra。
|
||||
func NewToolCallExtra(blockID, stage, toolName, status, summary, argumentsPreview string) *OpenAIChunkExtra {
|
||||
return &OpenAIChunkExtra{
|
||||
Kind: StreamExtraKindToolCall,
|
||||
BlockID: blockID,
|
||||
Stage: stage,
|
||||
DisplayMode: StreamDisplayModeCard,
|
||||
Tool: &StreamToolExtra{
|
||||
Name: toolName,
|
||||
Status: status,
|
||||
Summary: summary,
|
||||
ArgumentsPreview: argumentsPreview,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewToolResultExtra 创建“工具结果”事件的 extra。
|
||||
func NewToolResultExtra(
|
||||
blockID string,
|
||||
stage string,
|
||||
toolName string,
|
||||
status string,
|
||||
summary string,
|
||||
argumentsPreview string,
|
||||
argumentView map[string]any,
|
||||
resultView map[string]any,
|
||||
) *OpenAIChunkExtra {
|
||||
return &OpenAIChunkExtra{
|
||||
Kind: StreamExtraKindToolResult,
|
||||
BlockID: blockID,
|
||||
Stage: stage,
|
||||
DisplayMode: StreamDisplayModeCard,
|
||||
Tool: &StreamToolExtra{
|
||||
Name: toolName,
|
||||
Status: status,
|
||||
Summary: summary,
|
||||
ArgumentsPreview: argumentsPreview,
|
||||
ArgumentView: argumentView,
|
||||
ResultView: resultView,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewConfirmRequestExtra 创建“待确认”事件的 extra。
|
||||
func NewConfirmRequestExtra(blockID, stage, interactionID, title, summary string) *OpenAIChunkExtra {
|
||||
return &OpenAIChunkExtra{
|
||||
Kind: StreamExtraKindConfirm,
|
||||
BlockID: blockID,
|
||||
Stage: stage,
|
||||
DisplayMode: StreamDisplayModeCard,
|
||||
Confirm: &StreamConfirmExtra{
|
||||
InteractionID: interactionID,
|
||||
Title: title,
|
||||
Summary: summary,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewInterruptExtra 创建“中断”事件的 extra。
|
||||
func NewInterruptExtra(blockID, stage, interactionID, interactionType, summary string) *OpenAIChunkExtra {
|
||||
return &OpenAIChunkExtra{
|
||||
Kind: StreamExtraKindInterrupt,
|
||||
BlockID: blockID,
|
||||
Stage: stage,
|
||||
DisplayMode: StreamDisplayModeCard,
|
||||
Interrupt: &StreamInterruptExtra{
|
||||
InteractionID: interactionID,
|
||||
Type: interactionType,
|
||||
Summary: summary,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewBusinessCardExtra 创建“业务结果卡片”事件的 extra。
|
||||
func NewBusinessCardExtra(blockID, stage string, businessCard *StreamBusinessCardExtra) *OpenAIChunkExtra {
|
||||
return &OpenAIChunkExtra{
|
||||
Kind: StreamExtraKindBusinessCard,
|
||||
BlockID: blockID,
|
||||
Stage: stage,
|
||||
DisplayMode: StreamDisplayModeCard,
|
||||
BusinessCard: businessCard,
|
||||
}
|
||||
}
|
||||
|
||||
// NewScheduleCompletedExtra 创建”排程完毕”卡片事件的 extra。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 仅作为前端渲染”排程完毕小卡片”的信号,不携带排程数据;
|
||||
// 2. 前端收到此事件后,自行通过对话 ID 调用现有接口拉取排程详情;
|
||||
// 3. 触发条件:CommonState.HasScheduleChanges == true 且 IsCompleted()。
|
||||
func NewScheduleCompletedExtra(blockID, stage string) *OpenAIChunkExtra {
|
||||
return &OpenAIChunkExtra{
|
||||
Kind: StreamExtraKindScheduleCompleted,
|
||||
BlockID: blockID,
|
||||
Stage: stage,
|
||||
DisplayMode: StreamDisplayModeCard,
|
||||
}
|
||||
}
|
||||
|
||||
// NewFinishExtra 创建”收尾完成”事件的 extra。
|
||||
func NewFinishExtra(blockID, stage string) *OpenAIChunkExtra {
|
||||
return &OpenAIChunkExtra{
|
||||
Kind: StreamExtraKindFinish,
|
||||
BlockID: blockID,
|
||||
Stage: stage,
|
||||
DisplayMode: StreamDisplayModeReplace,
|
||||
}
|
||||
}
|
||||
|
||||
func buildOpenAIChunkPayload(requestID, modelName string, created int64, delta OpenAIChunkDelta, finishReason *string, extra *OpenAIChunkExtra) (string, error) {
|
||||
// 1. 若既没有 role,也没有正文/思考,也没有 finish_reason,且也没有 extra,则视为“空块”,直接跳过。
|
||||
// 2. 这样后续 emitter 即使拆成“结构化事件 + 文本事件”双轨,也能复用统一的空块兜底。
|
||||
if delta.Role == "" && delta.Content == "" && delta.ReasoningContent == "" && finishReason == nil && !hasStreamExtra(extra) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
choices := make([]OpenAIChunkChoice, 0, 1)
|
||||
if delta.Role != "" || delta.Content != "" || delta.ReasoningContent != "" || finishReason != nil {
|
||||
choices = append(choices, OpenAIChunkChoice{
|
||||
Index: 0,
|
||||
Delta: delta,
|
||||
FinishReason: finishReason,
|
||||
})
|
||||
}
|
||||
|
||||
dto := OpenAIChunkResponse{
|
||||
ID: requestID,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: created,
|
||||
Model: modelName,
|
||||
Choices: choices,
|
||||
Extra: extra,
|
||||
}
|
||||
data, err := json.Marshal(dto)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func hasStreamExtra(extra *OpenAIChunkExtra) bool {
|
||||
if extra == nil {
|
||||
return false
|
||||
}
|
||||
return extra.Kind != "" ||
|
||||
extra.BlockID != "" ||
|
||||
extra.Stage != "" ||
|
||||
extra.DisplayMode != "" ||
|
||||
extra.ThinkingSummary != nil ||
|
||||
extra.Status != nil ||
|
||||
extra.Tool != nil ||
|
||||
extra.Confirm != nil ||
|
||||
extra.Interrupt != nil ||
|
||||
extra.BusinessCard != nil ||
|
||||
len(extra.Meta) > 0
|
||||
}
|
||||
599
backend/services/agent/stream/reasoning_digestor.go
Normal file
599
backend/services/agent/stream/reasoning_digestor.go
Normal file
@@ -0,0 +1,599 @@
|
||||
package agentstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultReasoningDigestMinNewRunes = 120
|
||||
defaultReasoningDigestMinNewTokens = 80
|
||||
defaultReasoningDigestMinInterval = 3 * time.Second
|
||||
)
|
||||
|
||||
// ReasoningSummaryFunc 负责真正调用摘要模型。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 该函数只负责“把输入整理成一份摘要结果”,不负责调度、节流、正文闸门和结果丢弃;
|
||||
// 2. 返回值里的 short/detail 由模型或适配层填写;
|
||||
// 3. summary_seq / final / duration_seconds 由 ReasoningDigestor 统一补齐,避免上层重复维护运行态字段。
|
||||
type ReasoningSummaryFunc func(ctx context.Context, input ReasoningSummaryInput) (StreamThinkingSummaryExtra, error)
|
||||
|
||||
// ReasoningSummarySink 负责消费一条已经通过闸门校验的摘要结果。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 常见用法是把结果交给 ChunkEmitter.EmitThinkingSummary;
|
||||
// 2. 该回调不参与单飞、重试、水位线判断;
|
||||
// 3. 回调为 nil 时,Digestor 仍会维护 LatestSummary,方便调用方按需主动拉取。
|
||||
type ReasoningSummarySink func(summary StreamThinkingSummaryExtra)
|
||||
|
||||
// ReasoningSummaryInput 是注入给摘要模型调用方的统一输入。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. FullReasoning 提供完整 reasoning 缓冲区,适合做“全量重摘要”;
|
||||
// 2. DeltaReasoning + PreviousSummary 提供增量上下文,适合做“旧摘要续写”;
|
||||
// 3. CandidateSeq / Final / DurationSeconds 仅表达调度层意图,不要求模型原样回填。
|
||||
type ReasoningSummaryInput struct {
|
||||
FullReasoning string `json:"full_reasoning,omitempty"`
|
||||
DeltaReasoning string `json:"delta_reasoning,omitempty"`
|
||||
PreviousSummary *StreamThinkingSummaryExtra `json:"previous_summary,omitempty"`
|
||||
CandidateSeq int `json:"candidate_seq,omitempty"`
|
||||
Final bool `json:"final,omitempty"`
|
||||
DurationSeconds float64 `json:"duration_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// ReasoningDigestorOptions 描述 reasoning 摘要器的调度参数。
|
||||
type ReasoningDigestorOptions struct {
|
||||
SummaryFunc ReasoningSummaryFunc
|
||||
SummarySink ReasoningSummarySink
|
||||
BaseContext context.Context
|
||||
MinNewRunes int
|
||||
MinNewTokens int
|
||||
MinInterval time.Duration
|
||||
SummaryTimeout time.Duration
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// ReasoningDigestor 负责把流式 reasoning 文本整理成“低频摘要事件”。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责缓冲、单飞、水位线、正文闸门、Flush/Close,不直接依赖 AgentService;
|
||||
// 2. 只通过 SummaryFunc / SummarySink 两个函数注入模型调用与结果消费,不在这里选模型;
|
||||
// 3. 一旦正文开始或显式关闸,后续摘要结果即使返回成功也必须丢弃,避免前端和持久化出现越界数据。
|
||||
type ReasoningDigestor struct {
|
||||
summaryFunc ReasoningSummaryFunc
|
||||
summarySink ReasoningSummarySink
|
||||
baseContext context.Context
|
||||
minNewRunes int
|
||||
minNewTokens int
|
||||
minInterval time.Duration
|
||||
summaryTimeout time.Duration
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
cond *sync.Cond
|
||||
buffer strings.Builder
|
||||
deltaBuffer strings.Builder
|
||||
startedAt time.Time
|
||||
lastRequestAt time.Time
|
||||
pendingRunes int
|
||||
pendingTokens int
|
||||
summarySeq int
|
||||
latestSummary *StreamThinkingSummaryExtra
|
||||
finalEmitted bool
|
||||
inFlight bool
|
||||
gateClosed bool
|
||||
contentStarted bool
|
||||
closed bool
|
||||
timer *time.Timer
|
||||
timerArmed bool
|
||||
currentCancel context.CancelFunc
|
||||
}
|
||||
|
||||
type reasoningDigestCall struct {
|
||||
ctx context.Context
|
||||
stop context.CancelFunc
|
||||
input ReasoningSummaryInput
|
||||
final bool
|
||||
}
|
||||
|
||||
// NewReasoningDigestor 创建一个只关注“流式思考摘要调度”的核心对象。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 先校验 SummaryFunc;它是唯一必填项,因为 Digestor 不在本文件里选择模型;
|
||||
// 2. 再补齐默认水位线和最小时间间隔,让调用方即使只传核心依赖也能启动;
|
||||
// 3. 最后只初始化并发控制原语,不在构造阶段启动常驻主循环,避免引入额外 goroutine 生命周期负担。
|
||||
func NewReasoningDigestor(options ReasoningDigestorOptions) (*ReasoningDigestor, error) {
|
||||
if options.SummaryFunc == nil {
|
||||
return nil, errors.New("reasoning digestor: SummaryFunc 不能为空")
|
||||
}
|
||||
|
||||
if options.MinNewRunes < 0 {
|
||||
options.MinNewRunes = 0
|
||||
}
|
||||
if options.MinNewTokens < 0 {
|
||||
options.MinNewTokens = 0
|
||||
}
|
||||
if options.MinNewRunes == 0 && options.MinNewTokens == 0 {
|
||||
options.MinNewRunes = defaultReasoningDigestMinNewRunes
|
||||
options.MinNewTokens = defaultReasoningDigestMinNewTokens
|
||||
}
|
||||
if options.MinInterval <= 0 {
|
||||
options.MinInterval = defaultReasoningDigestMinInterval
|
||||
}
|
||||
if options.BaseContext == nil {
|
||||
options.BaseContext = context.Background()
|
||||
}
|
||||
if options.Now == nil {
|
||||
options.Now = time.Now
|
||||
}
|
||||
|
||||
digestor := &ReasoningDigestor{
|
||||
summaryFunc: options.SummaryFunc,
|
||||
summarySink: options.SummarySink,
|
||||
baseContext: options.BaseContext,
|
||||
minNewRunes: options.MinNewRunes,
|
||||
minNewTokens: options.MinNewTokens,
|
||||
minInterval: options.MinInterval,
|
||||
summaryTimeout: options.SummaryTimeout,
|
||||
now: options.Now,
|
||||
}
|
||||
digestor.cond = sync.NewCond(&digestor.mu)
|
||||
return digestor, nil
|
||||
}
|
||||
|
||||
// Append 追加一段 reasoning chunk,并按水位线决定是否后台触发摘要。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 先把原始 reasoning 文本写入 full buffer,保证 Flush/Close 可以拿到全量上下文;
|
||||
// 2. 再把本轮新增文本记入 deltaBuffer 与 rune/token 水位线,用于“最小新增量”判断;
|
||||
// 3. 若正文闸门已关闭,则只保留缓冲快照,不再调度摘要;
|
||||
// 4. 若当前已有摘要请求在飞,则只更新 dirty/latest,不排队第二个请求,等单飞请求返回后再决定是否补一次。
|
||||
func (d *ReasoningDigestor) Append(reasoning string) {
|
||||
if d == nil || reasoning == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var call reasoningDigestCall
|
||||
var shouldStart bool
|
||||
|
||||
d.mu.Lock()
|
||||
if d.closed {
|
||||
d.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if d.startedAt.IsZero() {
|
||||
d.startedAt = d.now()
|
||||
}
|
||||
d.buffer.WriteString(reasoning)
|
||||
|
||||
if d.gateClosed || d.contentStarted {
|
||||
d.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
d.deltaBuffer.WriteString(reasoning)
|
||||
d.pendingRunes += utf8.RuneCountInString(reasoning)
|
||||
d.pendingTokens += estimateReasoningTokens(reasoning)
|
||||
d.finalEmitted = false
|
||||
|
||||
call, shouldStart = d.prepareSummaryLocked(d.baseContext, false, false)
|
||||
d.mu.Unlock()
|
||||
|
||||
if shouldStart {
|
||||
go d.runSummary(call)
|
||||
}
|
||||
}
|
||||
|
||||
// MarkContentStarted 标记“正文已经开始输出”。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 该方法会直接关闭摘要闸门;
|
||||
// 2. 它不回收旧摘要结果,但会丢弃后续任何尚未完成的摘要调用;
|
||||
// 3. 调用后即使继续 Append reasoning,也只保留缓冲,不再触发新摘要。
|
||||
func (d *ReasoningDigestor) MarkContentStarted() {
|
||||
d.closeGate(true)
|
||||
}
|
||||
|
||||
// CloseGate 显式关闭摘要闸门,但不额外声明正文已经开始。
|
||||
func (d *ReasoningDigestor) CloseGate() {
|
||||
d.closeGate(false)
|
||||
}
|
||||
|
||||
// Flush 在正文尚未开始时尝试补发最后一次摘要。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 先等待当前单飞请求结束,避免 Flush 与后台自动摘要并发跑两次;
|
||||
// 2. 若正文已经开始或闸门已关,则直接返回,不再补摘要;
|
||||
// 3. 若此前已经发过 final 且没有新增 reasoning,则跳过,避免重复 final 事件;
|
||||
// 4. 其余场景会强制走一次摘要,即使新增量还没达到自动触发水位线。
|
||||
func (d *ReasoningDigestor) Flush(ctx context.Context) error {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
call, shouldStart := d.prepareFlushCall(ctx)
|
||||
if !shouldStart {
|
||||
return nil
|
||||
}
|
||||
return d.runSummary(call)
|
||||
}
|
||||
|
||||
// Close 结束摘要器生命周期。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 若正文还未开始,先尝试 Flush 一次 final 摘要;
|
||||
// 2. 再关闭闸门、停止等待中的定时器,并取消正在进行的摘要调用;
|
||||
// 3. 最后等待单飞调用完全退出,避免遗留后台 goroutine 持续写结果。
|
||||
func (d *ReasoningDigestor) Close(ctx context.Context) error {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := d.Flush(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
if d.closed {
|
||||
d.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
d.closed = true
|
||||
d.gateClosed = true
|
||||
d.stopTimerLocked()
|
||||
if d.currentCancel != nil {
|
||||
d.currentCancel()
|
||||
}
|
||||
for d.inFlight {
|
||||
d.cond.Wait()
|
||||
}
|
||||
d.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// LatestSummary 返回最近一次通过闸门校验并成功发布的摘要。
|
||||
func (d *ReasoningDigestor) LatestSummary() (StreamThinkingSummaryExtra, bool) {
|
||||
if d == nil {
|
||||
return StreamThinkingSummaryExtra{}, false
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if d.latestSummary == nil {
|
||||
return StreamThinkingSummaryExtra{}, false
|
||||
}
|
||||
return *cloneThinkingSummaryExtra(d.latestSummary), true
|
||||
}
|
||||
|
||||
func (d *ReasoningDigestor) closeGate(markContentStarted bool) {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
if markContentStarted {
|
||||
d.contentStarted = true
|
||||
}
|
||||
d.gateClosed = true
|
||||
d.pendingRunes = 0
|
||||
d.pendingTokens = 0
|
||||
d.deltaBuffer.Reset()
|
||||
d.stopTimerLocked()
|
||||
if d.currentCancel != nil {
|
||||
d.currentCancel()
|
||||
}
|
||||
d.mu.Unlock()
|
||||
}
|
||||
|
||||
func (d *ReasoningDigestor) prepareFlushCall(ctx context.Context) (reasoningDigestCall, bool) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if d.closed || d.gateClosed || d.contentStarted {
|
||||
return reasoningDigestCall{}, false
|
||||
}
|
||||
|
||||
d.stopTimerLocked()
|
||||
for d.inFlight {
|
||||
d.cond.Wait()
|
||||
if d.closed || d.gateClosed || d.contentStarted {
|
||||
return reasoningDigestCall{}, false
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(d.buffer.String()) == "" {
|
||||
return reasoningDigestCall{}, false
|
||||
}
|
||||
if d.finalEmitted && d.pendingRunes == 0 && d.pendingTokens == 0 {
|
||||
return reasoningDigestCall{}, false
|
||||
}
|
||||
return d.prepareSummaryLocked(ctx, true, true)
|
||||
}
|
||||
|
||||
func (d *ReasoningDigestor) prepareSummaryLocked(parent context.Context, force bool, final bool) (reasoningDigestCall, bool) {
|
||||
if d.closed || d.gateClosed || d.contentStarted || d.inFlight {
|
||||
return reasoningDigestCall{}, false
|
||||
}
|
||||
|
||||
fullReasoning := d.buffer.String()
|
||||
if strings.TrimSpace(fullReasoning) == "" {
|
||||
return reasoningDigestCall{}, false
|
||||
}
|
||||
|
||||
// 1. 自动摘要必须同时满足“新增量水位线 + 最小时间间隔”。
|
||||
// 2. 若新增量不足,则直接等待后续 Append,不做空转请求。
|
||||
// 3. 若时间间隔未到,则只挂一个定时器做兜底唤醒,避免排队多个请求。
|
||||
if !force {
|
||||
if !d.reachedWatermarkLocked() {
|
||||
return reasoningDigestCall{}, false
|
||||
}
|
||||
wait := d.nextAllowedIntervalLocked()
|
||||
if wait > 0 {
|
||||
d.armTimerLocked(wait)
|
||||
return reasoningDigestCall{}, false
|
||||
}
|
||||
}
|
||||
|
||||
callCtx, stop := d.newCallContext(parent)
|
||||
call := reasoningDigestCall{
|
||||
ctx: callCtx,
|
||||
stop: stop,
|
||||
input: ReasoningSummaryInput{
|
||||
FullReasoning: strings.Clone(fullReasoning),
|
||||
DeltaReasoning: strings.Clone(d.deltaBuffer.String()),
|
||||
PreviousSummary: cloneThinkingSummaryExtra(d.latestSummary),
|
||||
CandidateSeq: d.summarySeq + 1,
|
||||
Final: final,
|
||||
DurationSeconds: d.durationSecondsLocked(),
|
||||
},
|
||||
final: final,
|
||||
}
|
||||
|
||||
d.stopTimerLocked()
|
||||
d.inFlight = true
|
||||
d.lastRequestAt = d.now()
|
||||
d.pendingRunes = 0
|
||||
d.pendingTokens = 0
|
||||
d.deltaBuffer.Reset()
|
||||
d.currentCancel = stop
|
||||
|
||||
return call, true
|
||||
}
|
||||
|
||||
func (d *ReasoningDigestor) runSummary(call reasoningDigestCall) error {
|
||||
if call.stop == nil {
|
||||
return nil
|
||||
}
|
||||
defer call.stop()
|
||||
|
||||
summary, err := d.summaryFunc(call.ctx, call.input)
|
||||
if err != nil {
|
||||
// 1. 摘要失败时不把错误扩散回主流式链路,避免 reasoning 展示被摘要能力反向拖垮。
|
||||
// 2. 若失败期间又追加了新 reasoning,则仍按单飞规则尝试补下一次;否则等待后续 Append/Flush 兜底。
|
||||
_, _, nextCall, shouldStart := d.finishSummary(call.final, nil)
|
||||
if shouldStart {
|
||||
go d.runSummary(nextCall)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
normalized := normalizeThinkingSummary(summary, call.input.Final, call.input.DurationSeconds)
|
||||
emittedSummary, sink, nextCall, shouldStart := d.finishSummary(call.final, &normalized)
|
||||
if emittedSummary != nil && sink != nil {
|
||||
sink(*emittedSummary)
|
||||
}
|
||||
if shouldStart {
|
||||
go d.runSummary(nextCall)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *ReasoningDigestor) finishSummary(final bool, summary *StreamThinkingSummaryExtra) (*StreamThinkingSummaryExtra, ReasoningSummarySink, reasoningDigestCall, bool) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
d.inFlight = false
|
||||
d.currentCancel = nil
|
||||
d.cond.Broadcast()
|
||||
|
||||
var emittedSummary *StreamThinkingSummaryExtra
|
||||
var sink ReasoningSummarySink
|
||||
|
||||
// 1. 先判断正文闸门;正文一旦开始,所有晚到结果都必须丢弃。
|
||||
// 2. 再补齐 summary_seq/final/duration,并缓存 LatestSummary 供上层读取。
|
||||
// 3. 若当前请求期间又积累了新 reasoning,则只启动下一次单飞摘要,不排队多次。
|
||||
if summary != nil && !d.closed && !d.gateClosed && !d.contentStarted {
|
||||
normalized := *summary
|
||||
d.summarySeq++
|
||||
normalized.SummarySeq = d.summarySeq
|
||||
normalized.Final = final
|
||||
if normalized.DurationSeconds <= 0 {
|
||||
normalized.DurationSeconds = d.durationSecondsLocked()
|
||||
}
|
||||
d.latestSummary = cloneThinkingSummaryExtra(&normalized)
|
||||
d.finalEmitted = final
|
||||
emittedSummary = cloneThinkingSummaryExtra(&normalized)
|
||||
sink = d.summarySink
|
||||
}
|
||||
|
||||
if d.closed || d.gateClosed || d.contentStarted || final {
|
||||
return emittedSummary, sink, reasoningDigestCall{}, false
|
||||
}
|
||||
|
||||
nextCall, shouldStart := d.prepareSummaryLocked(d.baseContext, false, false)
|
||||
return emittedSummary, sink, nextCall, shouldStart
|
||||
}
|
||||
|
||||
func (d *ReasoningDigestor) reachedWatermarkLocked() bool {
|
||||
return reachedReasoningWatermark(d.pendingRunes, d.pendingTokens, d.minNewRunes, d.minNewTokens)
|
||||
}
|
||||
|
||||
func (d *ReasoningDigestor) nextAllowedIntervalLocked() time.Duration {
|
||||
if d.lastRequestAt.IsZero() {
|
||||
return 0
|
||||
}
|
||||
wait := d.minInterval - d.now().Sub(d.lastRequestAt)
|
||||
if wait < 0 {
|
||||
return 0
|
||||
}
|
||||
return wait
|
||||
}
|
||||
|
||||
func (d *ReasoningDigestor) armTimerLocked(wait time.Duration) {
|
||||
if wait <= 0 || d.closed || d.gateClosed || d.contentStarted {
|
||||
return
|
||||
}
|
||||
if d.timer == nil {
|
||||
d.timer = time.AfterFunc(wait, d.onTimer)
|
||||
d.timerArmed = true
|
||||
return
|
||||
}
|
||||
if d.timerArmed {
|
||||
d.timer.Reset(wait)
|
||||
return
|
||||
}
|
||||
d.timer.Reset(wait)
|
||||
d.timerArmed = true
|
||||
}
|
||||
|
||||
func (d *ReasoningDigestor) stopTimerLocked() {
|
||||
if d.timer == nil {
|
||||
return
|
||||
}
|
||||
if d.timer.Stop() {
|
||||
d.timerArmed = false
|
||||
return
|
||||
}
|
||||
d.timerArmed = false
|
||||
}
|
||||
|
||||
func (d *ReasoningDigestor) onTimer() {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var call reasoningDigestCall
|
||||
var shouldStart bool
|
||||
|
||||
d.mu.Lock()
|
||||
d.timerArmed = false
|
||||
call, shouldStart = d.prepareSummaryLocked(d.baseContext, false, false)
|
||||
d.mu.Unlock()
|
||||
|
||||
if shouldStart {
|
||||
go d.runSummary(call)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *ReasoningDigestor) newCallContext(parent context.Context) (context.Context, context.CancelFunc) {
|
||||
if parent == nil {
|
||||
parent = d.baseContext
|
||||
}
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
|
||||
baseCtx, baseCancel := context.WithCancel(parent)
|
||||
if d.summaryTimeout <= 0 {
|
||||
return baseCtx, baseCancel
|
||||
}
|
||||
|
||||
timeoutCtx, timeoutCancel := context.WithTimeout(baseCtx, d.summaryTimeout)
|
||||
return timeoutCtx, func() {
|
||||
timeoutCancel()
|
||||
baseCancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *ReasoningDigestor) durationSecondsLocked() float64 {
|
||||
if d.startedAt.IsZero() {
|
||||
return 0
|
||||
}
|
||||
duration := d.now().Sub(d.startedAt)
|
||||
if duration <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(duration.Milliseconds()) / 1000
|
||||
}
|
||||
|
||||
func reachedReasoningWatermark(pendingRunes, pendingTokens, minRunes, minTokens int) bool {
|
||||
if minRunes > 0 && pendingRunes >= minRunes {
|
||||
return true
|
||||
}
|
||||
if minTokens > 0 && pendingTokens >= minTokens {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeThinkingSummary(summary StreamThinkingSummaryExtra, final bool, durationSeconds float64) StreamThinkingSummaryExtra {
|
||||
summary.ShortSummary = strings.TrimSpace(summary.ShortSummary)
|
||||
summary.DetailSummary = strings.TrimSpace(summary.DetailSummary)
|
||||
|
||||
// 1. 短摘要只是实时展示兜底,允许从长摘要压一个默认值。
|
||||
// 2. 反过来不能把短摘要补成 detail_summary,否则会绕过“短摘要不持久化”的产品语义。
|
||||
// 3. 若模型没有给 detail_summary,timeline 层会跳过持久化,仅保留本次 SSE 展示。
|
||||
if summary.ShortSummary == "" {
|
||||
summary.ShortSummary = summary.DetailSummary
|
||||
}
|
||||
summary.Final = final
|
||||
if summary.DurationSeconds <= 0 {
|
||||
summary.DurationSeconds = durationSeconds
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func cloneThinkingSummaryExtra(src *StreamThinkingSummaryExtra) *StreamThinkingSummaryExtra {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *src
|
||||
return &clone
|
||||
}
|
||||
|
||||
func estimateReasoningTokens(text string) int {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
asciiRunes := 0
|
||||
totalTokens := 0
|
||||
for _, r := range text {
|
||||
switch {
|
||||
case unicode.IsSpace(r):
|
||||
if asciiRunes > 0 {
|
||||
totalTokens += compactASCIITokens(asciiRunes)
|
||||
asciiRunes = 0
|
||||
}
|
||||
case r <= unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)):
|
||||
asciiRunes++
|
||||
default:
|
||||
if asciiRunes > 0 {
|
||||
totalTokens += compactASCIITokens(asciiRunes)
|
||||
asciiRunes = 0
|
||||
}
|
||||
totalTokens++
|
||||
}
|
||||
}
|
||||
if asciiRunes > 0 {
|
||||
totalTokens += compactASCIITokens(asciiRunes)
|
||||
}
|
||||
return totalTokens
|
||||
}
|
||||
|
||||
func compactASCIITokens(asciiRunes int) int {
|
||||
if asciiRunes <= 0 {
|
||||
return 0
|
||||
}
|
||||
return max(1, (asciiRunes+3)/4)
|
||||
}
|
||||
37
backend/services/agent/stream/sse_adapter.go
Normal file
37
backend/services/agent/stream/sse_adapter.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package agentstream
|
||||
|
||||
import "log"
|
||||
|
||||
// NewSSEPayloadEmitter 创建将 chunk 事件写入 outChan 的 emitter。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 接收 outChan(SSE 输出通道),返回 PayloadEmitter 函数;
|
||||
// 2. 只把原始 JSON payload 写入通道,不添加 "data: " 前缀和 "\n\n" 后缀;
|
||||
// 3. SSE 格式化("data: " + payload + "\n\n")由 API 层的 writeSSEData 统一处理;
|
||||
// 4. 通道满时静默丢弃并返回 nil,让图继续完成状态持久化,避免因客户端超时而丢失快照。
|
||||
//
|
||||
// 使用示例:
|
||||
//
|
||||
// emitter := NewSSEPayloadEmitter(outChan)
|
||||
// chunkEmitter := NewChunkEmitter(emitter, requestID, modelName, created)
|
||||
// chunkEmitter.EmitAssistantText("", "", "hello", true)
|
||||
func NewSSEPayloadEmitter(outChan chan<- string) PayloadEmitter {
|
||||
return func(payload string) error {
|
||||
if outChan == nil {
|
||||
return nil
|
||||
}
|
||||
if payload == "" {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case outChan <- payload:
|
||||
return nil
|
||||
default:
|
||||
// 通道已满:客户端可能已断开或消费过慢。
|
||||
// 静默丢弃此 chunk,让图继续执行并完成状态持久化。
|
||||
// 客户端重连后可从 Redis 快照恢复,不需要这条消息。
|
||||
log.Printf("[WARN] SSE outChan full, dropping payload (len=%d)", len(payload))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
41
backend/services/agent/stream/usage.go
Normal file
41
backend/services/agent/stream/usage.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package agentstream
|
||||
|
||||
import "github.com/cloudwego/eino/schema"
|
||||
|
||||
// CloneUsage 深拷贝 TokenUsage。
|
||||
func CloneUsage(usage *schema.TokenUsage) *schema.TokenUsage {
|
||||
if usage == nil {
|
||||
return nil
|
||||
}
|
||||
copied := *usage
|
||||
return &copied
|
||||
}
|
||||
|
||||
// MergeUsage 合并两段 usage,取更大值。
|
||||
// 适用于同一次调用不同流分片的 usage 收敛。
|
||||
func MergeUsage(base *schema.TokenUsage, incoming *schema.TokenUsage) *schema.TokenUsage {
|
||||
if incoming == nil {
|
||||
return CloneUsage(base)
|
||||
}
|
||||
if base == nil {
|
||||
return CloneUsage(incoming)
|
||||
}
|
||||
|
||||
merged := *base
|
||||
if incoming.PromptTokens > merged.PromptTokens {
|
||||
merged.PromptTokens = incoming.PromptTokens
|
||||
}
|
||||
if incoming.CompletionTokens > merged.CompletionTokens {
|
||||
merged.CompletionTokens = incoming.CompletionTokens
|
||||
}
|
||||
if incoming.TotalTokens > merged.TotalTokens {
|
||||
merged.TotalTokens = incoming.TotalTokens
|
||||
}
|
||||
if incoming.PromptTokenDetails.CachedTokens > merged.PromptTokenDetails.CachedTokens {
|
||||
merged.PromptTokenDetails.CachedTokens = incoming.PromptTokenDetails.CachedTokens
|
||||
}
|
||||
if incoming.CompletionTokensDetails.ReasoningTokens > merged.CompletionTokensDetails.ReasoningTokens {
|
||||
merged.CompletionTokensDetails.ReasoningTokens = incoming.CompletionTokensDetails.ReasoningTokens
|
||||
}
|
||||
return &merged
|
||||
}
|
||||
495
backend/services/agent/sv/agent.go
Normal file
495
backend/services/agent/sv/agent.go
Normal file
@@ -0,0 +1,495 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/conv"
|
||||
"github.com/LoveLosita/smartflow/backend/dao"
|
||||
outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox"
|
||||
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
|
||||
memoryobserve "github.com/LoveLosita/smartflow/backend/memory/observe"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/LoveLosita/smartflow/backend/pkg"
|
||||
eventsvc "github.com/LoveLosita/smartflow/backend/service/events"
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentprompt "github.com/LoveLosita/smartflow/backend/services/agent/prompt"
|
||||
agenttools "github.com/LoveLosita/smartflow/backend/services/agent/tools"
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type AgentService struct {
|
||||
llmService *llmservice.Service
|
||||
repo *dao.AgentDAO
|
||||
taskRepo *dao.TaskDAO
|
||||
cacheDAO *dao.CacheDAO
|
||||
agentCache *dao.AgentCache
|
||||
activeScheduleDAO *dao.ActiveScheduleDAO
|
||||
activeScheduleSessionDAO *dao.ActiveScheduleSessionDAO
|
||||
eventPublisher outboxinfra.EventPublisher
|
||||
|
||||
// ── 排程计划依赖(函数注入,避免 service 包循环依赖)──
|
||||
|
||||
// SmartPlanningMultiRawFunc 是可选注入能力:
|
||||
// 1. 负责多任务类粗排;
|
||||
// 2. 当前主链路主要依赖 HybridScheduleWithPlanMultiFunc,可不强制使用。
|
||||
SmartPlanningMultiRawFunc func(ctx context.Context, userID int, taskClassIDs []int) ([]model.UserWeekSchedule, []model.TaskClassItem, error)
|
||||
// HybridScheduleWithPlanMultiFunc 是排程链路核心依赖:
|
||||
// 1. 负责把“多任务类粗排结果 + 既有日程”合并成 HybridEntries;
|
||||
// 2. daily/weekly ReAct 全部基于这个结果继续优化。
|
||||
HybridScheduleWithPlanMultiFunc func(ctx context.Context, userID int, taskClassIDs []int) ([]model.HybridScheduleEntry, []model.TaskClassItem, error)
|
||||
// ResolvePlanningWindowFunc 负责把 task_class_ids 解析成”全局排程窗口”的相对周/天边界。
|
||||
//
|
||||
// 作用:
|
||||
// 1. 给周级 Move 增加硬边界,避免首尾不足一周时移出有效日期范围;
|
||||
// 2. 该函数只做”窗口解析”,不负责粗排与混排计算。
|
||||
ResolvePlanningWindowFunc func(ctx context.Context, userID int, taskClassIDs []int) (startWeek, startDay, endWeek, endDay int, err error)
|
||||
|
||||
// ── 任务紧急性提升依赖(函数注入,避免 service 包循环依赖)──
|
||||
|
||||
// GetTasksWithUrgencyPromotionFunc 读取用户任务并应用读时紧急性提升 + 异步落库触发。
|
||||
// 未注入时,QueryTasksForTool 回退到旧逻辑(纯内存提升,不持久化)。
|
||||
GetTasksWithUrgencyPromotionFunc func(ctx context.Context, userID int) ([]model.Task, error)
|
||||
|
||||
// ── agent 依赖(由 cmd/start.go 通过 Set* 方法注入)──
|
||||
toolRegistry *agenttools.ToolRegistry
|
||||
scheduleProvider agentmodel.ScheduleStateProvider
|
||||
agentStateStore agentmodel.AgentStateStore
|
||||
compactionStore agentmodel.CompactionStore
|
||||
quickTaskDeps agentmodel.QuickTaskDeps
|
||||
memoryReader MemoryReader
|
||||
memoryCfg memorymodel.Config
|
||||
memoryObserver memoryobserve.Observer
|
||||
memoryMetrics memoryobserve.MetricsRecorder
|
||||
activeRerunFunc ActiveScheduleSessionRerunFunc
|
||||
}
|
||||
|
||||
// NewAgentService 构造 AgentService。
|
||||
// 这里通过依赖注入把“模型、仓储、缓存、异步持久化通道”统一交给服务层管理,
|
||||
// 便于后续在单测中替换实现,或在启动流程中按环境切换配置。
|
||||
func NewAgentService(
|
||||
llmService *llmservice.Service,
|
||||
repo *dao.AgentDAO,
|
||||
taskRepo *dao.TaskDAO,
|
||||
cacheDAO *dao.CacheDAO,
|
||||
agentRedis *dao.AgentCache,
|
||||
activeScheduleDAO *dao.ActiveScheduleDAO,
|
||||
activeSessionDAO *dao.ActiveScheduleSessionDAO,
|
||||
eventPublisher outboxinfra.EventPublisher,
|
||||
) *AgentService {
|
||||
// 全局注册一次 token 采集 callback:
|
||||
// 1. 只注册一次,避免重复处理;
|
||||
// 2. 只有带 RequestTokenMeter 的请求上下文才会真正累加。
|
||||
ensureTokenMeterCallbackRegistered()
|
||||
|
||||
return &AgentService{
|
||||
llmService: llmService,
|
||||
repo: repo,
|
||||
taskRepo: taskRepo,
|
||||
cacheDAO: cacheDAO,
|
||||
agentCache: agentRedis,
|
||||
activeScheduleDAO: activeScheduleDAO,
|
||||
activeScheduleSessionDAO: activeSessionDAO,
|
||||
eventPublisher: eventPublisher,
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeConversationID 规范会话 ID。
|
||||
// 规则:
|
||||
// 1) 去除首尾空白;
|
||||
// 2) 若为空则生成 UUID,保证后续缓存/数据库操作始终有合法 chat_id。
|
||||
func normalizeConversationID(chatID string) string {
|
||||
trimmed := strings.TrimSpace(chatID)
|
||||
if trimmed == "" {
|
||||
return uuid.NewString()
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// thinkingModeToBool 将前端传入的 thinking 模式转换为旧链路所需的 bool 值。
|
||||
// 仅 "true" 返回 true,其余("false"/"auto"/"")均返回 false。
|
||||
func thinkingModeToBool(mode string) bool {
|
||||
return strings.TrimSpace(strings.ToLower(mode)) == "true"
|
||||
}
|
||||
|
||||
// pickChatModel 根据请求选择模型。
|
||||
// 当前约定:
|
||||
// - 旧链路已全面切到 agent graph,这里仅作为 runNormalChatFlow 回退时的模型选择入口;
|
||||
// - 统一返回 Pro 模型,旧 strategist 参数不再生效。
|
||||
func (s *AgentService) pickChatModel(requestModel string) (*llmservice.Client, string) {
|
||||
if s == nil || s.llmService == nil {
|
||||
return nil, "pro"
|
||||
}
|
||||
return s.llmService.ProClient(), "pro"
|
||||
}
|
||||
|
||||
// PersistChatHistory 是 Agent 聊天链路唯一的“消息持久化入口”。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责根据当前部署模式选择“异步 outbox”或“同步直写 DB”;
|
||||
// 2. 负责把统一 DTO(ChatHistoryPersistPayload)交给下游基础设施;
|
||||
// 3. 不负责 Redis 上下文写入(Redis 由调用方在链路中先行处理);
|
||||
// 4. 不负责消费完成回调(异步模式下由 outbox 消费者负责最终落库)。
|
||||
func (s *AgentService) PersistChatHistory(ctx context.Context, payload model.ChatHistoryPersistPayload) error {
|
||||
// 1. 未注入事件发布器时(例如本地极简环境),直接同步写 DB。
|
||||
// 这样可以保证功能不依赖 Kafka 也能跑通。
|
||||
if s.eventPublisher == nil {
|
||||
return s.repo.SaveChatHistory(
|
||||
ctx,
|
||||
payload.UserID,
|
||||
payload.ConversationID,
|
||||
payload.Role,
|
||||
payload.Message,
|
||||
payload.ReasoningContent,
|
||||
payload.ReasoningDurationSeconds,
|
||||
payload.TokensConsumed,
|
||||
"",
|
||||
)
|
||||
}
|
||||
// 2. 已启用异步总线时,只发布“持久化请求事件”,不在请求路径阻塞 Kafka。
|
||||
// 2.1 发布成功仅代表“事件安全入队”,实际落库由消费者异步完成。
|
||||
return eventsvc.PublishChatHistoryPersistRequested(ctx, s.eventPublisher, payload)
|
||||
}
|
||||
|
||||
// saveChatHistoryReliable 是历史兼容别名。
|
||||
// 迁移策略:先保留旧方法名,避免同轮改动跨文件过大;后续可统一替换为 PersistChatHistory。
|
||||
func (s *AgentService) saveChatHistoryReliable(ctx context.Context, payload model.ChatHistoryPersistPayload) error {
|
||||
return s.PersistChatHistory(ctx, payload)
|
||||
}
|
||||
|
||||
func mergeAgentReasoningText(parts ...string) string {
|
||||
merged := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
text := strings.TrimSpace(part)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
merged = append(merged, text)
|
||||
}
|
||||
return strings.Join(merged, "\n\n")
|
||||
}
|
||||
|
||||
func readAgentExtraString(extra map[string]any, key string) string {
|
||||
if len(extra) == 0 {
|
||||
return ""
|
||||
}
|
||||
raw, ok := extra[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
text, ok := raw.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func readAgentExtraInt(extra map[string]any, key string) int {
|
||||
if len(extra) == 0 {
|
||||
return 0
|
||||
}
|
||||
raw, ok := extra[key]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
// 1. 前端的历史消息 id 在本地态里可能是 string,也可能是 number。
|
||||
// 2. 重试链路只要这里解析失败,父消息 id 就会退化成 0,后续写库自然会落成 NULL。
|
||||
// 3. 因此这里统一做“宽松整型解析”,兼容 JSON number、前端字符串数字和常见整数类型。
|
||||
value, ok := parseAgentLooseInt(raw)
|
||||
if !ok || value <= 0 {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func readAgentExtraBool(extra map[string]any, key string) bool {
|
||||
if len(extra) == 0 {
|
||||
return false
|
||||
}
|
||||
raw, ok := extra[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case float64:
|
||||
return v != 0
|
||||
case string:
|
||||
return strings.ToLower(strings.TrimSpace(v)) == "true"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// readAgentExtraIntSlice 从 extra 中提取 []int。
|
||||
// 支持 JSON 数组格式([]any,每个元素为 float64/int)。
|
||||
func readAgentExtraIntSlice(extra map[string]any, key string) []int {
|
||||
if len(extra) == 0 {
|
||||
return nil
|
||||
}
|
||||
raw, ok := extra[key]
|
||||
if !ok || raw == nil {
|
||||
return nil
|
||||
}
|
||||
arr, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
result := make([]int, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
if v, ok := parseAgentLooseInt(item); ok && v > 0 {
|
||||
result = append(result, v)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// parseAgentLooseInt 负责把 extra 中的”弱类型数字”归一成 int。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责兼容前端 JSON 解码后的常见数值类型,以及字符串形式的数字。
|
||||
// 2. 不负责业务语义校验;例如是否必须大于 0,由调用方自行决定。
|
||||
// 3. 解析失败时返回 ok=false,调用方可按各自场景走兜底逻辑。
|
||||
func parseAgentLooseInt(raw any) (value int, ok bool) {
|
||||
switch v := raw.(type) {
|
||||
case int:
|
||||
return v, true
|
||||
case int32:
|
||||
return int(v), true
|
||||
case int64:
|
||||
return int(v), true
|
||||
case float64:
|
||||
return int(v), true
|
||||
case json.Number:
|
||||
if parsed, err := v.Int64(); err == nil {
|
||||
return int(parsed), true
|
||||
}
|
||||
if parsed, err := v.Float64(); err == nil {
|
||||
return int(parsed), true
|
||||
}
|
||||
return 0, false
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(v)
|
||||
if trimmed == "" {
|
||||
return 0, false
|
||||
}
|
||||
parsed, err := strconv.Atoi(trimmed)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// pushErrNonBlocking 向错误通道“尽力投递”错误。
|
||||
// 目的:
|
||||
// 1) 避免 goroutine 在 errChan 满时被阻塞导致泄漏;
|
||||
// 2) 保证主业务协程不因“错误上报拥塞”卡死。
|
||||
func pushErrNonBlocking(errChan chan error, err error) {
|
||||
select {
|
||||
case errChan <- err:
|
||||
default:
|
||||
log.Printf("错误通道已满,丢弃错误: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// runNormalChatFlow 执行普通流式聊天链路(非随口记)。
|
||||
// 该函数被两处复用:
|
||||
// 1) 用户输入本就不是随口记;
|
||||
// 2) 开启随口记进度推送后,最终判定“非随口记”时回落到普通聊天。
|
||||
func (s *AgentService) runNormalChatFlow(
|
||||
ctx context.Context,
|
||||
selectedModel *llmservice.Client,
|
||||
resolvedModelName string,
|
||||
userMessage string,
|
||||
userPersisted bool,
|
||||
assistantReasoningPrefix string,
|
||||
assistantReasoningStartedAt *time.Time,
|
||||
ifThinking bool,
|
||||
userID int,
|
||||
chatID string,
|
||||
traceID string,
|
||||
requestStart time.Time,
|
||||
outChan chan<- string,
|
||||
errChan chan error,
|
||||
) {
|
||||
// 1. 先尝试从 Redis 读历史,命中可直接进入模型推理,减少 DB 压力。
|
||||
chatHistory, err := s.agentCache.GetHistory(ctx, chatID)
|
||||
if err != nil {
|
||||
pushErrNonBlocking(errChan, err)
|
||||
return
|
||||
}
|
||||
|
||||
cacheMiss := false
|
||||
if chatHistory == nil {
|
||||
// 2. 缓存未命中时回源 DB,并转换为 Eino message 格式。
|
||||
cacheMiss = true
|
||||
histories, hisErr := s.repo.GetUserChatHistories(ctx, userID, pkg.HistoryFetchLimitByModel(resolvedModelName), chatID)
|
||||
if hisErr != nil {
|
||||
pushErrNonBlocking(errChan, hisErr)
|
||||
return
|
||||
}
|
||||
chatHistory = conv.ToEinoMessages(histories)
|
||||
}
|
||||
|
||||
// 3. 计算本次请求可用的历史 token 预算,并执行历史裁剪。
|
||||
// 这样可以在上下文增长时稳定控制模型窗口,避免超长上下文引发报错或高延迟。
|
||||
historyBudget := pkg.HistoryTokenBudgetByModel(resolvedModelName, agentprompt.SystemPrompt, userMessage)
|
||||
trimmedHistory, totalHistoryTokens, keptHistoryTokens, droppedCount := pkg.TrimHistoryByTokenBudget(chatHistory, historyBudget)
|
||||
chatHistory = trimmedHistory
|
||||
|
||||
// 4. 根据裁剪后历史长度更新 Redis 会话窗口配置,并主动执行窗口收敛。
|
||||
targetWindow := pkg.CalcSessionWindowSize(len(chatHistory))
|
||||
if err = s.agentCache.SetSessionWindowSize(ctx, chatID, targetWindow); err != nil {
|
||||
log.Printf("设置历史窗口失败 chat=%s: %v", chatID, err)
|
||||
}
|
||||
if err = s.agentCache.EnforceHistoryWindow(ctx, chatID); err != nil {
|
||||
log.Printf("执行历史窗口裁剪失败 chat=%s: %v", chatID, err)
|
||||
}
|
||||
|
||||
if droppedCount > 0 {
|
||||
log.Printf("历史裁剪: chat=%s total_tokens=%d kept_tokens=%d dropped=%d budget=%d target_window=%d",
|
||||
chatID, totalHistoryTokens, keptHistoryTokens, droppedCount, historyBudget, targetWindow)
|
||||
}
|
||||
|
||||
if cacheMiss {
|
||||
// 5. 回源后把历史回填到 Redis,减少下一次请求的冷启动成本。
|
||||
if err = s.agentCache.BackfillHistory(ctx, chatID, chatHistory); err != nil {
|
||||
pushErrNonBlocking(errChan, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 6.0. 没有可用模型时,直接中止普通聊天,避免写入半截用户消息后没有后续回复。
|
||||
if selectedModel == nil {
|
||||
pushErrNonBlocking(errChan, errors.New("llm client is not ready"))
|
||||
return
|
||||
}
|
||||
|
||||
// 6. 执行真正的流式聊天。
|
||||
// fullText 用于后续写 Redis/持久化,outChan 用于把流片段实时推给前端。
|
||||
fullText, _, reasoningDurationSeconds, streamUsage, streamErr := s.streamChatFallback(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, chatHistory, outChan, assistantReasoningStartedAt, userID, chatID)
|
||||
if streamErr != nil {
|
||||
pushErrNonBlocking(errChan, streamErr)
|
||||
return
|
||||
}
|
||||
|
||||
// 6.1 流式 usage 并入请求级 token 统计器:
|
||||
// 6.1.1 route/quicknote/taskquery 等 Generate 调用由 callback 自动累加;
|
||||
// 6.1.2 主对话 Stream usage 在这里手动补齐。
|
||||
addSchemaUsageIntoRequest(ctx, streamUsage)
|
||||
requestTokenSnapshot := snapshotRequestTokenMeter(ctx)
|
||||
requestTotalTokens := requestTokenSnapshot.TotalTokens
|
||||
if requestTotalTokens <= 0 && streamUsage != nil {
|
||||
// 兜底:若 callback/meter 未生效,至少使用流式 usage 保底记账。
|
||||
requestTotalTokens = normalizeUsageTotal(streamUsage.TotalTokens, streamUsage.PromptTokens, streamUsage.CompletionTokens)
|
||||
}
|
||||
|
||||
// 7. 后置持久化(用户消息):
|
||||
// 7.1 先写 Redis,保证“最新会话上下文”可立即用于下一轮推理;
|
||||
// 7.2 再走可靠持久化入口(outbox 或同步 DB)。
|
||||
if !userPersisted {
|
||||
userMsg := &schema.Message{Role: schema.User, Content: userMessage}
|
||||
if err = s.agentCache.PushMessage(ctx, chatID, userMsg); err != nil {
|
||||
log.Printf("写入用户消息到 Redis 失败: %v", err)
|
||||
}
|
||||
|
||||
if err = s.PersistChatHistory(ctx, model.ChatHistoryPersistPayload{
|
||||
UserID: userID,
|
||||
ConversationID: chatID,
|
||||
Role: "user",
|
||||
Message: userMessage,
|
||||
ReasoningContent: "",
|
||||
ReasoningDurationSeconds: 0,
|
||||
// 口径 B:用户消息固定记 0;本轮总 token 统一记在助手消息。
|
||||
TokensConsumed: 0,
|
||||
}); err != nil {
|
||||
pushErrNonBlocking(errChan, err)
|
||||
return
|
||||
}
|
||||
if _, timelineErr := s.appendConversationTimelineEvent(
|
||||
ctx,
|
||||
userID,
|
||||
chatID,
|
||||
model.AgentTimelineKindUserText,
|
||||
"user",
|
||||
userMessage,
|
||||
nil,
|
||||
0,
|
||||
); timelineErr != nil {
|
||||
pushErrNonBlocking(errChan, timelineErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 普通聊天链路也需要把助手回复写入 Redis,
|
||||
// 否则会出现“数据库有助手消息,但 Redis 最新会话只有用户消息”的口径不一致。
|
||||
// 8. 后置持久化(助手消息):
|
||||
// 8.1 先写 Redis,保证下一轮上下文可见;
|
||||
// 8.2 再异步可靠落库,失败通过 errChan 回传给上层。
|
||||
assistantMsg := &schema.Message{Role: schema.Assistant, Content: fullText}
|
||||
if reasoningDurationSeconds > 0 {
|
||||
assistantMsg.Extra = map[string]any{"reasoning_duration_seconds": reasoningDurationSeconds}
|
||||
}
|
||||
if err = s.agentCache.PushMessage(context.Background(), chatID, assistantMsg); err != nil {
|
||||
log.Printf("写入助手消息到 Redis 失败: %v", err)
|
||||
}
|
||||
|
||||
if saveErr := s.PersistChatHistory(context.Background(), model.ChatHistoryPersistPayload{
|
||||
UserID: userID,
|
||||
ConversationID: chatID,
|
||||
Role: "assistant",
|
||||
Message: fullText,
|
||||
ReasoningContent: "",
|
||||
ReasoningDurationSeconds: reasoningDurationSeconds,
|
||||
// 口径B:助手消息记录“本轮请求总 token”。
|
||||
TokensConsumed: requestTotalTokens,
|
||||
}); saveErr != nil {
|
||||
pushErrNonBlocking(errChan, saveErr)
|
||||
} else {
|
||||
assistantTimelinePayload := map[string]any{}
|
||||
if reasoningDurationSeconds > 0 {
|
||||
assistantTimelinePayload["reasoning_duration_seconds"] = reasoningDurationSeconds
|
||||
}
|
||||
if _, timelineErr := s.appendConversationTimelineEvent(
|
||||
context.Background(),
|
||||
userID,
|
||||
chatID,
|
||||
model.AgentTimelineKindAssistantText,
|
||||
"assistant",
|
||||
fullText,
|
||||
assistantTimelinePayload,
|
||||
requestTotalTokens,
|
||||
); timelineErr != nil {
|
||||
pushErrNonBlocking(errChan, timelineErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 9. 在主回复完成后异步尝试生成会话标题(仅首次、仅标题为空时生效)。
|
||||
// 该步骤不影响当前请求返回时延,也不影响聊天主链路成功与否。
|
||||
s.ensureConversationTitleAsync(userID, chatID)
|
||||
}
|
||||
|
||||
func (s *AgentService) AgentChat(ctx context.Context, userMessage string, thinkingMode string, modelName string, userID int, chatID string, extra map[string]any) (<-chan string, <-chan error) {
|
||||
requestStart := time.Now()
|
||||
traceID := uuid.NewString()
|
||||
|
||||
outChan := make(chan string, 256)
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
defer close(outChan)
|
||||
s.runAgentGraph(ctx, userMessage, thinkingMode, modelName, userID, chatID, extra, traceID, requestStart, outChan, errChan)
|
||||
}()
|
||||
|
||||
return outChan, errChan
|
||||
}
|
||||
388
backend/services/agent/sv/agent_active_schedule_session.go
Normal file
388
backend/services/agent/sv/agent_active_schedule_session.go
Normal file
@@ -0,0 +1,388 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// ActiveScheduleSessionRerunFunc 表示主动调度 session 被聊天入口接管后,如何同步推进 rerun。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责把“当前 session + 用户回复”推进为新的主动调度结果;
|
||||
// 2. 不负责决定 session 何时创建,也不负责通知投递;
|
||||
// 3. 返回的结果只面向聊天入口的可见消息和 session 状态回写。
|
||||
type ActiveScheduleSessionRerunFunc func(
|
||||
ctx context.Context,
|
||||
session *model.ActiveScheduleSessionSnapshot,
|
||||
userMessage string,
|
||||
traceID string,
|
||||
requestStart time.Time,
|
||||
) (*ActiveScheduleSessionRerunResult, error)
|
||||
|
||||
// ActiveScheduleSessionRerunResult 是主动调度 rerun 的最小返回结果。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只承载聊天入口需要回写的可见消息、业务卡片和 session 状态;
|
||||
// 2. 不直接暴露 DAO 行,也不承载 worker / notification 的副作用;
|
||||
// 3. AssistantText 为空时,调用方可降级为使用卡片摘要。
|
||||
type ActiveScheduleSessionRerunResult struct {
|
||||
AssistantText string
|
||||
BusinessCard *agentstream.StreamBusinessCardExtra
|
||||
SessionState model.ActiveScheduleSessionState
|
||||
SessionStatus string
|
||||
PreviewID string
|
||||
}
|
||||
|
||||
// SetActiveScheduleSessionRerunFunc 注入主动调度 rerun 入口。
|
||||
func (s *AgentService) SetActiveScheduleSessionRerunFunc(fn ActiveScheduleSessionRerunFunc) {
|
||||
s.activeRerunFunc = fn
|
||||
}
|
||||
|
||||
// loadActiveScheduleSessionByConversation 尽量从缓存 + 数据库读取当前会话的主动调度 session。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先读 Redis 热缓存,命中则直接返回;
|
||||
// 2. 缓存未命中再回源数据库,避免把 session 状态逻辑绑死在缓存上;
|
||||
// 3. 回源成功后尽力回填缓存,减少下一轮聊天入口的 DB 压力。
|
||||
func (s *AgentService) loadActiveScheduleSessionByConversation(ctx context.Context, userID int, chatID string) (*model.ActiveScheduleSessionSnapshot, error) {
|
||||
if s == nil || s.activeScheduleSessionDAO == nil {
|
||||
return nil, nil
|
||||
}
|
||||
normalizedChatID := strings.TrimSpace(chatID)
|
||||
if userID <= 0 || normalizedChatID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if s.cacheDAO != nil {
|
||||
cached, err := s.cacheDAO.GetActiveScheduleSessionFromConversationCache(ctx, userID, normalizedChatID)
|
||||
if err != nil {
|
||||
log.Printf("读取主动调度 session 缓存失败 user=%d chat=%s err=%v", userID, normalizedChatID, err)
|
||||
} else if cached != nil {
|
||||
return cached, nil
|
||||
}
|
||||
}
|
||||
|
||||
row, err := s.activeScheduleSessionDAO.GetActiveScheduleSessionByConversationID(ctx, userID, normalizedChatID)
|
||||
if err != nil || row == nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.cacheDAO != nil {
|
||||
if cacheErr := s.cacheDAO.SetActiveScheduleSessionToCache(ctx, row); cacheErr != nil {
|
||||
log.Printf("回填主动调度 session 缓存失败 user=%d chat=%s err=%v", userID, normalizedChatID, cacheErr)
|
||||
}
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// persistActiveScheduleSessionBestEffort 负责把主动调度 session 的最新状态同步回 MySQL 和 Redis。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. MySQL 是最终真相,先写表再回填缓存;
|
||||
// 2. 缓存失败只记日志,不影响主流程;
|
||||
// 3. 调用方需要先把 snapshot 改成最终状态,再交给这里落盘。
|
||||
func (s *AgentService) persistActiveScheduleSessionBestEffort(ctx context.Context, snapshot *model.ActiveScheduleSessionSnapshot) error {
|
||||
if s == nil || s.activeScheduleSessionDAO == nil || snapshot == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(snapshot.SessionID) == "" {
|
||||
return errors.New("active schedule session_id 不能为空")
|
||||
}
|
||||
|
||||
if err := s.activeScheduleSessionDAO.UpsertActiveScheduleSession(ctx, snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1. 重新读取一遍,拿到数据库侧最终落表后的标准快照,减少缓存和 DB 的口径漂移。
|
||||
// 2. 如果重读失败,也不影响主链路返回,只要主表已成功写入即可。
|
||||
normalized, err := s.activeScheduleSessionDAO.GetActiveScheduleSessionBySessionID(ctx, snapshot.SessionID)
|
||||
if err == nil && normalized != nil {
|
||||
snapshot = normalized
|
||||
}
|
||||
|
||||
if s.cacheDAO != nil {
|
||||
if cacheErr := s.cacheDAO.SetActiveScheduleSessionToCache(ctx, snapshot); cacheErr != nil {
|
||||
log.Printf("回填主动调度 session 缓存失败 session=%s err=%v", snapshot.SessionID, cacheErr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// persistActiveScheduleTriggerPreviewBestEffort 负责把 rerun 产生的新 preview_id 同步回 trigger。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只维护 trigger -> preview 的审计指针,不修改 preview 内容,也不推进 confirm/apply 状态;
|
||||
// 2. trigger_id 或 preview_id 为空时直接跳过,避免把不完整 rerun 结果写入触发记录;
|
||||
// 3. DAO 未注入时保持迁移期兼容,调用方仍以 session 写回作为主流程。
|
||||
func (s *AgentService) persistActiveScheduleTriggerPreviewBestEffort(ctx context.Context, triggerID string, previewID string) error {
|
||||
if s == nil || s.activeScheduleDAO == nil {
|
||||
return nil
|
||||
}
|
||||
normalizedTriggerID := strings.TrimSpace(triggerID)
|
||||
normalizedPreviewID := strings.TrimSpace(previewID)
|
||||
if normalizedTriggerID == "" || normalizedPreviewID == "" {
|
||||
return nil
|
||||
}
|
||||
return s.activeScheduleDAO.UpdateTriggerFields(ctx, normalizedTriggerID, map[string]any{
|
||||
"preview_id": &normalizedPreviewID,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleActiveScheduleSessionChat 处理被主动调度 session 占管的聊天入口。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先读 session,判断当前 conversation 是否仍在 waiting_user_reply / rerunning 占管期;
|
||||
// 2. 占管期间先把用户消息写入历史和时间线,保证会话内容不丢失;
|
||||
// 3. waiting_user_reply 进入 rerunning,并同步调用主动调度 rerun;
|
||||
// 4. rerunning 则只提示“正在重跑”,避免同一 conversation 被并发重复推进;
|
||||
// 5. 终态或非占管态直接放行普通 agent。
|
||||
func (s *AgentService) handleActiveScheduleSessionChat(
|
||||
ctx context.Context,
|
||||
userMessage string,
|
||||
traceID string,
|
||||
requestStart time.Time,
|
||||
userID int,
|
||||
chatID string,
|
||||
resolvedModelName string,
|
||||
outChan chan<- string,
|
||||
errChan chan error,
|
||||
) (bool, error) {
|
||||
session, err := s.loadActiveScheduleSessionByConversation(ctx, userID, chatID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if session == nil || !isActiveScheduleSessionBlockingStatus(session.Status) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
trimmedMessage := strings.TrimSpace(userMessage)
|
||||
if trimmedMessage != "" {
|
||||
// 1. 主动调度占管期间,用户每次回复仍然要进入正常会话历史。
|
||||
// 2. 这样后续刷新聊天页时,用户可见消息、时间线和 session 状态不会彼此脱节。
|
||||
if err := s.persistNewAgentConversationMessage(ctx, userID, chatID, schema.UserMessage(trimmedMessage), 0); err != nil {
|
||||
return true, err
|
||||
}
|
||||
}
|
||||
|
||||
switch session.Status {
|
||||
case model.ActiveScheduleSessionStatusWaitingUserReply:
|
||||
if trimmedMessage == "" {
|
||||
assistantText := strings.TrimSpace(session.State.PendingQuestion)
|
||||
if assistantText == "" {
|
||||
assistantText = "请先补充主动调度需要的关键信息。"
|
||||
}
|
||||
if err := s.persistNewAgentConversationMessage(ctx, userID, chatID, schema.AssistantMessage(assistantText, nil), 0); err != nil {
|
||||
return true, err
|
||||
}
|
||||
emitActiveScheduleAssistantChunk(outChan, traceID, resolvedModelName, requestStart, assistantText, nil)
|
||||
return true, nil
|
||||
}
|
||||
// 1. 收到用户补充信息后,先把 session 切成 rerunning,避免并发请求继续按旧状态走普通聊天。
|
||||
// 2. 这个阶段只是状态切换,不代表 graph 已经完成。
|
||||
// 3. 这里必须使用 DB CAS 抢占 rerun 权限,避免两条补充消息同时读到 waiting_user_reply 后重复生成 preview。
|
||||
switched, err := s.activeScheduleSessionDAO.TryTransitionActiveScheduleSessionStatusBySessionID(
|
||||
ctx,
|
||||
session.SessionID,
|
||||
model.ActiveScheduleSessionStatusWaitingUserReply,
|
||||
model.ActiveScheduleSessionStatusRerunning,
|
||||
)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
if !switched {
|
||||
if err := s.respondActiveScheduleRerunning(ctx, userID, chatID, traceID, resolvedModelName, requestStart, outChan); err != nil {
|
||||
return true, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
session.Status = model.ActiveScheduleSessionStatusRerunning
|
||||
if s.cacheDAO != nil {
|
||||
if cacheErr := s.cacheDAO.SetActiveScheduleSessionToCache(ctx, session); cacheErr != nil {
|
||||
log.Printf("回填主动调度 rerunning session 缓存失败 session=%s err=%v", session.SessionID, cacheErr)
|
||||
}
|
||||
}
|
||||
return true, s.runActiveScheduleSessionRerun(ctx, session, trimmedMessage, traceID, requestStart, resolvedModelName, outChan, errChan)
|
||||
case model.ActiveScheduleSessionStatusRerunning:
|
||||
// 1. rerunning 是占管中的过渡态,说明当前会话已经在重跑或刚开始重跑。
|
||||
// 2. 这里不再触发第二次 rerun,只给用户一个可见的等待提示。
|
||||
if trimmedMessage != "" {
|
||||
if err := s.respondActiveScheduleRerunning(ctx, userID, chatID, traceID, resolvedModelName, requestStart, outChan); err != nil {
|
||||
return true, err
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// respondActiveScheduleRerunning 负责在重复补充命中并发保护时写入可见提示。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只写聊天历史和 SSE 文本,不推进 session、trigger、preview 状态;
|
||||
// 2. 用于 rerunning 状态或 CAS 抢占失败后的兜底提示,避免再次触发 graph;
|
||||
// 3. 写入失败时返回 error,让上层按聊天入口的错误通道处理。
|
||||
func (s *AgentService) respondActiveScheduleRerunning(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
chatID string,
|
||||
traceID string,
|
||||
resolvedModelName string,
|
||||
requestStart time.Time,
|
||||
outChan chan<- string,
|
||||
) error {
|
||||
assistantText := "主动调度正在重新生成建议,请稍后再试。"
|
||||
if err := s.persistNewAgentConversationMessage(ctx, userID, chatID, schema.AssistantMessage(assistantText, nil), 0); err != nil {
|
||||
return err
|
||||
}
|
||||
emitActiveScheduleAssistantChunk(outChan, traceID, resolvedModelName, requestStart, assistantText, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runActiveScheduleSessionRerun 负责把 waiting_user_reply 的用户补充同步推进成新的主动调度结果。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责聊天入口的最小编排,不复制 worker / notification 链路;
|
||||
// 2. 成功时把新 preview / ask_user / close 的结果写回 session + timeline;
|
||||
// 3. 失败时把 session 标成 failed,方便后续排障。
|
||||
func (s *AgentService) runActiveScheduleSessionRerun(
|
||||
ctx context.Context,
|
||||
session *model.ActiveScheduleSessionSnapshot,
|
||||
userMessage string,
|
||||
traceID string,
|
||||
requestStart time.Time,
|
||||
resolvedModelName string,
|
||||
outChan chan<- string,
|
||||
errChan chan error,
|
||||
) error {
|
||||
if s == nil || s.activeRerunFunc == nil {
|
||||
return errors.New("主动调度 rerun 未接入")
|
||||
}
|
||||
if session == nil {
|
||||
return errors.New("active schedule session 不能为空")
|
||||
}
|
||||
|
||||
result, err := s.activeRerunFunc(ctx, session, userMessage, traceID, requestStart)
|
||||
if err != nil {
|
||||
session.Status = model.ActiveScheduleSessionStatusFailed
|
||||
session.State.FailedReason = strings.TrimSpace(err.Error())
|
||||
_ = s.persistActiveScheduleSessionBestEffort(ctx, session)
|
||||
return err
|
||||
}
|
||||
if result == nil {
|
||||
result = &ActiveScheduleSessionRerunResult{}
|
||||
}
|
||||
|
||||
finalStatus := strings.TrimSpace(result.SessionStatus)
|
||||
if finalStatus == "" {
|
||||
if result.BusinessCard != nil {
|
||||
finalStatus = model.ActiveScheduleSessionStatusReadyPreview
|
||||
} else {
|
||||
finalStatus = model.ActiveScheduleSessionStatusWaitingUserReply
|
||||
}
|
||||
}
|
||||
session.Status = finalStatus
|
||||
session.State = result.SessionState
|
||||
previewID := strings.TrimSpace(result.PreviewID)
|
||||
if previewID != "" {
|
||||
session.CurrentPreviewID = previewID
|
||||
}
|
||||
if session.Status == model.ActiveScheduleSessionStatusReadyPreview {
|
||||
session.State.PendingQuestion = ""
|
||||
session.State.MissingInfo = nil
|
||||
session.State.FailedReason = ""
|
||||
}
|
||||
|
||||
if previewID != "" {
|
||||
if err := s.persistActiveScheduleTriggerPreviewBestEffort(ctx, session.TriggerID, previewID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.persistActiveScheduleSessionBestEffort(ctx, session); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
assistantText := strings.TrimSpace(result.AssistantText)
|
||||
if assistantText == "" && result.BusinessCard != nil {
|
||||
assistantText = strings.TrimSpace(result.BusinessCard.Summary)
|
||||
}
|
||||
if assistantText == "" {
|
||||
assistantText = "主动调度建议已更新。"
|
||||
}
|
||||
|
||||
// 1. 把新结果写进 conversation history,保证刷新后仍然能看到 rerun 的正文。
|
||||
// 2. 再追加业务卡片时间线,前端可以按 timeline 重建主动调度卡片。
|
||||
if err := s.persistNewAgentConversationMessage(ctx, session.UserID, session.ConversationID, schema.AssistantMessage(assistantText, nil), 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if result.BusinessCard != nil {
|
||||
if _, err := s.appendConversationTimelineEvent(
|
||||
ctx,
|
||||
session.UserID,
|
||||
session.ConversationID,
|
||||
model.AgentTimelineKindBusinessCard,
|
||||
"assistant",
|
||||
assistantText,
|
||||
map[string]any{"business_card": result.BusinessCard},
|
||||
0,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
emitActiveScheduleAssistantChunk(outChan, traceID, resolvedModelName, requestStart, assistantText, nil)
|
||||
if result.BusinessCard != nil {
|
||||
emitActiveScheduleBusinessCardChunk(outChan, session.SessionID, traceID, resolvedModelName, requestStart, result.BusinessCard)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isActiveScheduleSessionBlockingStatus(status string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case model.ActiveScheduleSessionStatusWaitingUserReply,
|
||||
model.ActiveScheduleSessionStatusRerunning:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func emitActiveScheduleAssistantChunk(outChan chan<- string, traceID string, modelName string, requestStart time.Time, text string, extra *agentstream.OpenAIChunkExtra) {
|
||||
payload, err := agentstream.ToOpenAIAssistantChunkWithExtra(traceID, modelName, requestStart.Unix(), strings.TrimSpace(text), true, extra)
|
||||
if err != nil {
|
||||
log.Printf("构造主动调度 assistant chunk 失败 trace=%s err=%v", traceID, err)
|
||||
return
|
||||
}
|
||||
pushChunkNonBlocking(outChan, payload)
|
||||
}
|
||||
|
||||
func emitActiveScheduleBusinessCardChunk(outChan chan<- string, blockID string, traceID string, modelName string, requestStart time.Time, card *agentstream.StreamBusinessCardExtra) {
|
||||
if card == nil {
|
||||
return
|
||||
}
|
||||
payload, err := agentstream.ToOpenAIStreamWithExtra(nil, traceID, modelName, requestStart.Unix(), true, agentstream.NewBusinessCardExtra(blockID, "active_schedule_session", card))
|
||||
if err != nil {
|
||||
log.Printf("构造主动调度 business card chunk 失败 trace=%s err=%v", traceID, err)
|
||||
return
|
||||
}
|
||||
pushChunkNonBlocking(outChan, payload)
|
||||
}
|
||||
|
||||
func pushChunkNonBlocking(outChan chan<- string, payload string) {
|
||||
if outChan == nil || strings.TrimSpace(payload) == "" {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case outChan <- payload:
|
||||
default:
|
||||
log.Printf("主动调度 SSE 通道已满,丢弃 payload")
|
||||
}
|
||||
}
|
||||
727
backend/services/agent/sv/agent_graph.go
Normal file
727
backend/services/agent/sv/agent_graph.go
Normal file
@@ -0,0 +1,727 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentconv "github.com/LoveLosita/smartflow/backend/services/agent/conv"
|
||||
agentgraph "github.com/LoveLosita/smartflow/backend/services/agent/graph"
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
agenttools "github.com/LoveLosita/smartflow/backend/services/agent/tools"
|
||||
schedule "github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/conv"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/LoveLosita/smartflow/backend/pkg"
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
eventsvc "github.com/LoveLosita/smartflow/backend/service/events"
|
||||
agentprompt "github.com/LoveLosita/smartflow/backend/services/agent/prompt"
|
||||
)
|
||||
|
||||
const (
|
||||
agentHistoryKindKey = "newagent_history_kind"
|
||||
agentHistoryKindLoopClosed = "execute_loop_closed"
|
||||
)
|
||||
|
||||
// runAgentGraph 运行 agent 通用 graph,直接替换旧 agent 路由逻辑。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责构造 AgentGraphRunInput(RuntimeState、ConversationContext、Request、Deps);
|
||||
// 2. 负责将 outChan 适配为 ChunkEmitter;
|
||||
// 3. 负责调用 graph.RunAgentGraph;
|
||||
// 4. 负责持久化聊天历史(复用现有逻辑)。
|
||||
//
|
||||
// 设计原则:
|
||||
// 1. 直接走 agent graph,不再经过旧的 agentrouter 路由决策;
|
||||
// 2. 所有任务类型(chat、task、quick_note)都由 graph 内部 LLM 决策;
|
||||
// 3. 状态恢复、工具执行、确认流程全部由 graph 节点处理。
|
||||
func (s *AgentService) runAgentGraph(
|
||||
ctx context.Context,
|
||||
userMessage string,
|
||||
thinkingMode string,
|
||||
modelName string,
|
||||
userID int,
|
||||
chatID string,
|
||||
extra map[string]any,
|
||||
traceID string,
|
||||
requestStart time.Time,
|
||||
outChan chan<- string,
|
||||
errChan chan error,
|
||||
) {
|
||||
requestCtx, _ := withRequestTokenMeter(ctx)
|
||||
if s == nil || s.llmService == nil {
|
||||
// 0. agent 主链强依赖 llm-service;装配漏传时直接返回错误,避免 nil receiver panic。
|
||||
pushErrNonBlocking(errChan, errors.New("agent llm service is not initialized"))
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 规范会话 ID 和模型选择。
|
||||
chatID = normalizeConversationID(chatID)
|
||||
_, resolvedModelName := s.pickChatModel(modelName)
|
||||
|
||||
// 2. 确保会话存在(优先缓存,必要时回源 DB)。
|
||||
result, err := s.agentCache.GetConversationStatus(requestCtx, chatID)
|
||||
if err != nil {
|
||||
pushErrNonBlocking(errChan, err)
|
||||
return
|
||||
}
|
||||
if !result {
|
||||
innerResult, ifErr := s.repo.IfChatExists(requestCtx, userID, chatID)
|
||||
if ifErr != nil {
|
||||
pushErrNonBlocking(errChan, ifErr)
|
||||
return
|
||||
}
|
||||
if !innerResult {
|
||||
if _, err = s.repo.CreateNewChat(userID, chatID); err != nil {
|
||||
pushErrNonBlocking(errChan, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err = s.agentCache.SetConversationStatus(requestCtx, chatID); err != nil {
|
||||
log.Printf("设置会话状态缓存失败 chat=%s: %v", chatID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. retry 机制已下线,不再构建重试元数据。
|
||||
|
||||
// 4. 如果当前 conversation 被主动调度 session 占管,先走 session 分支,不进入普通 agent。
|
||||
// 这样 waiting_user_reply / rerunning 期间,用户消息会先推动主动调度闭环,而不是误进自由聊天。
|
||||
if handled, sessionErr := s.handleActiveScheduleSessionChat(requestCtx, userMessage, traceID, requestStart, userID, chatID, resolvedModelName, outChan, errChan); sessionErr != nil {
|
||||
pushErrNonBlocking(errChan, sessionErr)
|
||||
return
|
||||
} else if handled {
|
||||
return
|
||||
}
|
||||
|
||||
// 5. 从 StateStore 加载或创建 RuntimeState。
|
||||
// 恢复场景(confirm/ask_user)同时拿到快照中保存的 ConversationContext,
|
||||
// 其中包含工具调用/结果等中间消息,保证后续 LLM 调用的消息链完整。
|
||||
runtimeState, savedConversationContext, savedScheduleState, savedOriginalScheduleState := s.loadOrCreateRuntimeState(requestCtx, chatID, userID)
|
||||
|
||||
// 6. 构造 ConversationContext。
|
||||
// 优先使用快照中恢复的 ConversationContext(含工具调用/结果),
|
||||
// 无快照时从 Redis LLM 历史缓存加载。
|
||||
var conversationContext *agentmodel.ConversationContext
|
||||
if savedConversationContext != nil {
|
||||
conversationContext = savedConversationContext
|
||||
// 把用户本轮输入追加到恢复的上下文中(与 loadConversationContext 行为一致)。
|
||||
if strings.TrimSpace(userMessage) != "" {
|
||||
conversationContext.AppendHistory(schema.UserMessage(userMessage))
|
||||
}
|
||||
} else {
|
||||
conversationContext = s.loadConversationContext(requestCtx, chatID, userMessage)
|
||||
}
|
||||
// 6.1. 在 graph 执行前统一补充与当前输入相关的记忆上下文(预取管线模式)。
|
||||
// 6.1.1 先读 Redis 预取缓存注入到 ConversationContext,再启动后台 goroutine 做完整检索;
|
||||
// 6.1.2 返回的 channel 传入 Deps,供 Execute/Plan 节点在启动前消费最新记忆;
|
||||
// 6.1.3 检索失败只降级为"本轮不注入记忆",不阻断主链路。
|
||||
memoryFuture := s.injectMemoryContext(requestCtx, conversationContext, userID, chatID, userMessage)
|
||||
|
||||
// 6.5 将前端传入的 thinkingMode 写入 CommonState,供 ChatNode 及下游节点读取。
|
||||
cs := runtimeState.EnsureCommonState()
|
||||
cs.ThinkingMode = thinkingMode
|
||||
|
||||
// 6.6 若 extra 携带 task_class_ids,校验后写入 CommonState(仅首轮/尚未设置时生效,跨轮持久化)。
|
||||
if taskClassIDs := readAgentExtraIntSlice(extra, "task_class_ids"); len(taskClassIDs) > 0 {
|
||||
cs := runtimeState.EnsureCommonState()
|
||||
if len(cs.TaskClassIDs) == 0 {
|
||||
if s.scheduleProvider == nil {
|
||||
pushErrNonBlocking(errChan, respond.WrongTaskClassID)
|
||||
return
|
||||
}
|
||||
metas, metaErr := s.scheduleProvider.LoadTaskClassMetas(requestCtx, userID, taskClassIDs)
|
||||
if metaErr != nil {
|
||||
pushErrNonBlocking(errChan, respond.WrongTaskClassID)
|
||||
return
|
||||
}
|
||||
cs.TaskClassIDs = taskClassIDs
|
||||
cs.TaskClasses = metas
|
||||
}
|
||||
}
|
||||
|
||||
cs = runtimeState.EnsureCommonState()
|
||||
|
||||
// 6.7 先把本轮用户输入落库,确保后续可见 assistant 消息按真实时间线追加。
|
||||
userMsg := schema.UserMessage(userMessage)
|
||||
if err := s.persistNewAgentConversationMessage(requestCtx, userID, chatID, userMsg, 0); err != nil {
|
||||
pushErrNonBlocking(errChan, err)
|
||||
return
|
||||
}
|
||||
|
||||
persistVisibleMessage := func(persistCtx context.Context, state *agentmodel.CommonState, msg *schema.Message) error {
|
||||
targetState := state
|
||||
if targetState == nil {
|
||||
targetState = runtimeState.EnsureCommonState()
|
||||
}
|
||||
if targetState != nil {
|
||||
if targetState.UserID <= 0 {
|
||||
targetState.UserID = userID
|
||||
}
|
||||
if strings.TrimSpace(targetState.ConversationID) == "" {
|
||||
targetState.ConversationID = chatID
|
||||
}
|
||||
}
|
||||
return s.persistNewAgentConversationMessage(persistCtx, userID, chatID, msg, 0)
|
||||
}
|
||||
|
||||
// 7. 构造 AgentGraphRequest。
|
||||
var (
|
||||
confirmAction string
|
||||
resumeInteractionID string
|
||||
)
|
||||
if len(extra) > 0 {
|
||||
confirmAction = readAgentExtraString(extra, "confirm_action")
|
||||
resumeInteractionID = readAgentExtraString(extra, "resume_interaction_id")
|
||||
}
|
||||
graphRequest := agentmodel.AgentGraphRequest{
|
||||
UserInput: userMessage,
|
||||
ConfirmAction: confirmAction,
|
||||
ResumeInteractionID: resumeInteractionID,
|
||||
AlwaysExecute: readAgentExtraBool(extra, "always_execute"),
|
||||
}
|
||||
graphRequest.Normalize()
|
||||
|
||||
// 8. 适配 LLM clients(统一从 llm-service 取出 agent 图所需模型,不再直接碰 AIHub)。
|
||||
// 8.1 Chat/Deliver 使用 Pro 模型:路由分流、闲聊、交付总结属于标准复杂度。
|
||||
// 8.2 Plan/Execute 使用 Max 模型:规划和 ReAct 循环需要深度推理能力。
|
||||
llmClients := s.llmService.NewAgentModelClients()
|
||||
chatClient := llmClients.Chat
|
||||
planClient := llmClients.Plan
|
||||
executeClient := llmClients.Execute
|
||||
deliverClient := llmClients.Deliver
|
||||
summaryClient := llmClients.Summary
|
||||
|
||||
// 9. 适配 SSE emitter。
|
||||
sseEmitter := agentstream.NewSSEPayloadEmitter(outChan)
|
||||
chunkEmitter := agentstream.NewChunkEmitter(sseEmitter, traceID, resolvedModelName, requestStart.Unix())
|
||||
chunkEmitter.SetReasoningSummaryFunc(s.makeReasoningSummaryFunc(summaryClient))
|
||||
// 关键卡片事件走统一时间线持久化,保证刷新后可重建。
|
||||
chunkEmitter.SetExtraEventHook(func(extra *agentstream.OpenAIChunkExtra) {
|
||||
s.persistAgentTimelineExtraEvent(context.Background(), userID, chatID, extra)
|
||||
})
|
||||
|
||||
// 10. 构造 AgentGraphDeps(由 cmd/start.go 注入的依赖)。
|
||||
deps := agentmodel.AgentGraphDeps{
|
||||
ChatClient: chatClient,
|
||||
PlanClient: planClient,
|
||||
ExecuteClient: executeClient,
|
||||
DeliverClient: deliverClient,
|
||||
ChunkEmitter: chunkEmitter,
|
||||
StateStore: s.agentStateStore,
|
||||
ToolRegistry: s.toolRegistry,
|
||||
ScheduleProvider: s.scheduleProvider,
|
||||
CompactionStore: s.compactionStore,
|
||||
RoughBuildFunc: s.makeRoughBuildFunc(),
|
||||
WriteSchedulePreview: s.makeWriteSchedulePreviewFunc(),
|
||||
MemoryFuture: memoryFuture,
|
||||
ThinkingPlan: viper.GetBool("agent.thinking.plan"),
|
||||
ThinkingExecute: viper.GetBool("agent.thinking.execute"),
|
||||
ThinkingDeliver: viper.GetBool("agent.thinking.deliver"),
|
||||
PersistVisibleMessage: persistVisibleMessage,
|
||||
QuickTaskDeps: s.quickTaskDeps,
|
||||
}
|
||||
|
||||
// 11. 构造 AgentGraphRunInput 并运行 graph。
|
||||
runInput := agentmodel.AgentGraphRunInput{
|
||||
RuntimeState: runtimeState,
|
||||
ConversationContext: conversationContext,
|
||||
ScheduleState: savedScheduleState,
|
||||
OriginalScheduleState: savedOriginalScheduleState,
|
||||
Request: graphRequest,
|
||||
Deps: deps,
|
||||
}
|
||||
|
||||
finalState, graphErr := agentgraph.RunAgentGraph(requestCtx, runInput)
|
||||
if graphErr != nil {
|
||||
// 1. 客户端断连导致的 context 取消,属于正常场景,不推错误通道也不跑 fallback。
|
||||
// 否则会刷 "错误通道已满" 日志噪音,且 fallback 在 ctx 已取消时也会失败。
|
||||
if errors.Is(graphErr, context.Canceled) || requestCtx.Err() != nil {
|
||||
log.Printf("[WARN] agent graph 因客户端断连中止 trace=%s chat=%s", traceID, chatID)
|
||||
return
|
||||
}
|
||||
log.Printf("[ERROR] agent graph 执行失败 trace=%s chat=%s: %v", traceID, chatID, graphErr)
|
||||
pushErrNonBlocking(errChan, fmt.Errorf("graph 执行失败: %w", graphErr))
|
||||
|
||||
// Graph 出错时回退普通聊天,保证可用性。回退使用 llm-service 的 Pro 模型。
|
||||
s.runNormalChatFlow(requestCtx, chatClient, resolvedModelName, userMessage, true, "", nil, thinkingModeToBool(thinkingMode), userID, chatID, traceID, requestStart, outChan, errChan)
|
||||
return
|
||||
}
|
||||
|
||||
// 12. 持久化聊天历史(用户消息 + 助手回复)。
|
||||
requestTotalTokens := snapshotRequestTokenMeter(requestCtx).TotalTokens
|
||||
s.adjustAgentRequestTokenUsage(requestCtx, userID, chatID, requestTotalTokens)
|
||||
// 12.5. 将最终状态快照异步写入 MySQL(通过 outbox)。
|
||||
// Deliver 节点已将快照保存到 Redis(2h TTL),此处通过 outbox 异步写入 MySQL 做永久存储。
|
||||
if finalState != nil {
|
||||
snapshot := &agentmodel.AgentStateSnapshot{
|
||||
RuntimeState: finalState.EnsureRuntimeState(),
|
||||
ConversationContext: finalState.EnsureConversationContext(),
|
||||
}
|
||||
eventsvc.PublishAgentStateSnapshot(requestCtx, s.eventPublisher, snapshot, chatID, userID)
|
||||
}
|
||||
|
||||
// 12.6. graph 完成后条件触发记忆抽取。
|
||||
// 说明:
|
||||
// 1. 只有本轮未走快捷随口记任务路径时才触发记忆抽取;
|
||||
// 2. 避免随口记创建的 Task 与记忆系统产生语义冲突。
|
||||
if finalState != nil {
|
||||
cs := finalState.EnsureRuntimeState().EnsureCommonState()
|
||||
if cs == nil || !cs.UsedQuickNote {
|
||||
if memErr := eventsvc.PublishMemoryExtractFromGraph(requestCtx, s.eventPublisher, userID, chatID, userMessage); memErr != nil {
|
||||
log.Printf("[WARN] graph 完成后发布记忆抽取事件失败 trace=%s chat=%s err=%v", traceID, chatID, memErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 排程预览缓存由 Deliver 节点负责写入(通过注入的 WriteSchedulePreview func),
|
||||
// 保证只有任务真正完成时才写,中断路径不写中间态。
|
||||
|
||||
// 13. 发送 OpenAI 兼容的流式结束标记,告知客户端 stream 已完成。
|
||||
_ = chunkEmitter.EmitDone()
|
||||
|
||||
// 14. 异步生成会话标题。
|
||||
s.ensureConversationTitleAsync(userID, chatID)
|
||||
}
|
||||
|
||||
// loadOrCreateRuntimeState 从 StateStore 加载或创建新的 RuntimeState。
|
||||
//
|
||||
// 返回值:
|
||||
// - RuntimeState:可持久化流程状态;
|
||||
// - ConversationContext:快照中保存的完整对话上下文(含工具调用/结果),
|
||||
// 仅在恢复已有快照时非 nil,新建会话时为 nil。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 快照中的 ConversationContext 包含 graph 执行期间的完整中间消息(工具调用、工具结果等),
|
||||
// 这些消息不会出现在 Redis LLM 历史缓存中;
|
||||
// 2. 恢复场景(confirm/ask_user)必须使用快照中的 ConversationContext,否则工具结果丢失,
|
||||
// 导致后续 LLM 调用收到非法的裸 Tool 消息,API 拒绝请求、连接断开。
|
||||
func (s *AgentService) loadOrCreateRuntimeState(ctx context.Context, chatID string, userID int) (*agentmodel.AgentRuntimeState, *agentmodel.ConversationContext, *schedule.ScheduleState, *schedule.ScheduleState) {
|
||||
newRT := func() (*agentmodel.AgentRuntimeState, *agentmodel.ConversationContext, *schedule.ScheduleState, *schedule.ScheduleState) {
|
||||
rt := agentmodel.NewAgentRuntimeState(nil)
|
||||
cs := rt.EnsureCommonState()
|
||||
cs.UserID = userID
|
||||
cs.ConversationID = chatID // saveAgentState 依赖此字段决定是否持久化
|
||||
return rt, nil, nil, nil
|
||||
}
|
||||
|
||||
if s.agentStateStore == nil {
|
||||
return newRT()
|
||||
}
|
||||
|
||||
snapshot, ok, err := s.agentStateStore.Load(ctx, chatID)
|
||||
log.Printf("[DEBUG] loadOrCreateRuntimeState chatID=%s ok=%v err=%v hasRuntime=%v hasPending=%v hasCtx=%v hasSchedule=%v hasOriginal=%v",
|
||||
chatID, ok, err,
|
||||
snapshot != nil && snapshot.RuntimeState != nil,
|
||||
snapshot != nil && snapshot.RuntimeState != nil && snapshot.RuntimeState.HasPendingInteraction(),
|
||||
snapshot != nil && snapshot.ConversationContext != nil,
|
||||
snapshot != nil && snapshot.ScheduleState != nil,
|
||||
snapshot != nil && snapshot.OriginalScheduleState != nil,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("加载 agent 状态失败 chat=%s: %v", chatID, err)
|
||||
return newRT()
|
||||
}
|
||||
if ok && snapshot != nil && snapshot.RuntimeState != nil {
|
||||
// 恢复运行态,确保身份信息与当前请求一致。
|
||||
cs := snapshot.RuntimeState.EnsureCommonState()
|
||||
cs.UserID = userID
|
||||
cs.ConversationID = chatID
|
||||
|
||||
// 1. 冷加载兜底:若上一轮已经收口且当前没有待恢复交互,说明本次是新一轮请求;
|
||||
// 2. 这里先重置执行期临时字段,避免旧 round/terminal 状态污染 chat 路由和后续 execute;
|
||||
// 3. 即使 chat 节点也有同条件重置,这里仍保留兜底,覆盖断线恢复或入口绕行场景。
|
||||
if !snapshot.RuntimeState.HasPendingInteraction() && cs.Phase == agentmodel.PhaseDone {
|
||||
terminalBefore := cs.TerminalStatus()
|
||||
roundBefore := cs.RoundUsed
|
||||
// 1. 仅"正常完成(completed)"写 loop 收口 marker:
|
||||
// 1.1 下一轮执行时,prompt 会把上一轮 loop 从 msg2 归档到 msg1;
|
||||
// 1.2 异常中断(aborted/exhausted)不写 marker,保留 msg2 便于后续续跑。
|
||||
if terminalBefore == agentmodel.FlowTerminalStatusCompleted {
|
||||
appendExecuteLoopClosedMarker(snapshot.ConversationContext)
|
||||
}
|
||||
cs.ResetForNextRun()
|
||||
log.Printf(
|
||||
"[DEBUG] loadOrCreateRuntimeState reset runtime for next run chat=%s round_before=%d terminal_before=%s",
|
||||
chatID,
|
||||
roundBefore,
|
||||
terminalBefore,
|
||||
)
|
||||
}
|
||||
|
||||
// 常规场景仍由 Chat 节点基于路由覆盖 Phase,这里只在"上一轮已 done"时做一次前置清理兜底。
|
||||
// 其余跨轮可复用状态(如任务类范围、会话历史、日程内存态)继续保留,支持连续对话调整日程。
|
||||
|
||||
originalScheduleState := snapshot.OriginalScheduleState
|
||||
if snapshot.ScheduleState != nil && originalScheduleState == nil {
|
||||
// 1. 兼容老快照:历史会话可能只存了 ScheduleState,没有 original 副本。
|
||||
// 2. 这里补一份克隆,保证后续节点拿到的仍是"恢复态 + 原始态"成对数据。
|
||||
// 3. 即便当前阶段不落库,这里也保留一致性,避免下一轮再出现语义漂移。
|
||||
originalScheduleState = snapshot.ScheduleState.Clone()
|
||||
}
|
||||
return snapshot.RuntimeState, snapshot.ConversationContext, snapshot.ScheduleState, originalScheduleState
|
||||
}
|
||||
return newRT()
|
||||
}
|
||||
|
||||
// appendExecuteLoopClosedMarker 在 ConversationContext 写入"上一轮 loop 正常收口"标记。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只追加轻量 marker 供 prompt 分层,不做历史摘要或裁剪;
|
||||
// 2. 若末尾已是同类 marker,则幂等跳过;
|
||||
// 3. context 为空时直接返回,避免冷启动异常。
|
||||
func appendExecuteLoopClosedMarker(conversationContext *agentmodel.ConversationContext) {
|
||||
if conversationContext == nil {
|
||||
return
|
||||
}
|
||||
history := conversationContext.HistorySnapshot()
|
||||
if len(history) > 0 {
|
||||
last := history[len(history)-1]
|
||||
if last != nil && last.Extra != nil {
|
||||
if kind, ok := last.Extra[agentHistoryKindKey].(string); ok && strings.TrimSpace(kind) == agentHistoryKindLoopClosed {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: "",
|
||||
Extra: map[string]any{
|
||||
agentHistoryKindKey: agentHistoryKindLoopClosed,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// loadConversationContext 加载对话历史,构造 ConversationContext。
|
||||
func (s *AgentService) loadConversationContext(ctx context.Context, chatID, userMessage string) *agentmodel.ConversationContext {
|
||||
// 从 Redis 加载历史。
|
||||
history, err := s.agentCache.GetHistory(ctx, chatID)
|
||||
if err != nil {
|
||||
log.Printf("加载历史失败 chat=%s: %v", chatID, err)
|
||||
history = nil
|
||||
}
|
||||
|
||||
// 缓存未命中时回源 DB。
|
||||
if history == nil {
|
||||
histories, hisErr := s.repo.GetUserChatHistories(ctx, 0, pkg.HistoryFetchLimitByModel("worker"), chatID)
|
||||
if hisErr != nil {
|
||||
log.Printf("从 DB 加载历史失败 chat=%s: %v", chatID, hisErr)
|
||||
} else {
|
||||
history = conv.ToEinoMessages(histories)
|
||||
// 回填到 Redis。
|
||||
if backfillErr := s.agentCache.BackfillHistory(ctx, chatID, history); backfillErr != nil {
|
||||
log.Printf("回填历史到 Redis 失败 chat=%s: %v", chatID, backfillErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构造 ConversationContext。
|
||||
conversationContext := agentmodel.NewConversationContext(agentprompt.SystemPrompt)
|
||||
if history != nil {
|
||||
conversationContext.ReplaceHistory(history)
|
||||
}
|
||||
|
||||
// 把用户本轮输入追加到历史(供 graph 使用)。
|
||||
if strings.TrimSpace(userMessage) != "" {
|
||||
conversationContext.AppendHistory(schema.UserMessage(userMessage))
|
||||
}
|
||||
|
||||
return conversationContext
|
||||
}
|
||||
|
||||
// persistNewAgentConversationMessage 负责把 agent 链路里"真正对用户可见"的消息统一落到 Redis + MySQL。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做单条消息的持久化,不做 graph 流程控制;
|
||||
// 2. TokensConsumed 由调用方显式传入,agent 逐条可见消息默认写 0;
|
||||
// 3. Redis 失败只记日志,DB 失败返回错误,便于调用方决定是否中止当前链路。
|
||||
func (s *AgentService) persistNewAgentConversationMessage(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
chatID string,
|
||||
msg *schema.Message,
|
||||
tokensConsumed int,
|
||||
) error {
|
||||
if s == nil || msg == nil {
|
||||
return nil
|
||||
}
|
||||
role := strings.TrimSpace(string(msg.Role))
|
||||
content := strings.TrimSpace(msg.Content)
|
||||
if role == "" || content == "" {
|
||||
return nil
|
||||
}
|
||||
if userID <= 0 || strings.TrimSpace(chatID) == "" {
|
||||
return fmt.Errorf("agent visible message persist: invalid conversation identity")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
persistMsg := &schema.Message{
|
||||
Role: msg.Role,
|
||||
Content: content,
|
||||
// 可见消息持久化只保存正文;模型 raw reasoning 改由 thinking_summary 生成用户可见摘要,
|
||||
// 避免历史接口或时间线刷新时重新暴露内部思考文本。
|
||||
ReasoningContent: "",
|
||||
}
|
||||
if len(msg.Extra) > 0 {
|
||||
persistMsg.Extra = make(map[string]any, len(msg.Extra))
|
||||
for key, value := range msg.Extra {
|
||||
persistMsg.Extra[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.agentCache.PushMessage(ctx, chatID, persistMsg); err != nil {
|
||||
log.Printf("写入 agent 可见消息到 Redis 失败 chat=%s role=%s: %v", chatID, role, err)
|
||||
}
|
||||
|
||||
reasoningDurationSeconds := 0
|
||||
if persistMsg.Extra != nil {
|
||||
switch v := persistMsg.Extra["reasoning_duration_seconds"].(type) {
|
||||
case int:
|
||||
reasoningDurationSeconds = v
|
||||
case int64:
|
||||
reasoningDurationSeconds = int(v)
|
||||
case float64:
|
||||
reasoningDurationSeconds = int(v)
|
||||
}
|
||||
}
|
||||
|
||||
persistPayload := model.ChatHistoryPersistPayload{
|
||||
UserID: userID,
|
||||
ConversationID: chatID,
|
||||
Role: role,
|
||||
Message: content,
|
||||
ReasoningContent: strings.TrimSpace(persistMsg.ReasoningContent),
|
||||
ReasoningDurationSeconds: reasoningDurationSeconds,
|
||||
TokensConsumed: tokensConsumed,
|
||||
}
|
||||
if err := s.PersistChatHistory(ctx, persistPayload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 统一写入会话时间线,保证正文与卡片可按单一 seq 顺序重建。
|
||||
timelineKind := model.AgentTimelineKindAssistantText
|
||||
switch role {
|
||||
case "user":
|
||||
timelineKind = model.AgentTimelineKindUserText
|
||||
case "assistant":
|
||||
timelineKind = model.AgentTimelineKindAssistantText
|
||||
}
|
||||
timelinePayload := map[string]any{}
|
||||
if reasoningDurationSeconds > 0 {
|
||||
timelinePayload["reasoning_duration_seconds"] = reasoningDurationSeconds
|
||||
}
|
||||
if _, err := s.appendConversationTimelineEvent(
|
||||
ctx,
|
||||
userID,
|
||||
chatID,
|
||||
timelineKind,
|
||||
role,
|
||||
content,
|
||||
timelinePayload,
|
||||
tokensConsumed,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// makeRoughBuildFunc 把 AgentService 上的 HybridScheduleWithPlanMultiFunc 封装成
|
||||
// agent 层的 RoughBuildFunc,将 HybridScheduleWithPlanMultiFunc 的结果转换为 RoughBuildPlacement。
|
||||
// HybridScheduleWithPlanMultiFunc 未注入时返回 nil,RoughBuild 节点会静默跳过粗排。
|
||||
//
|
||||
// 修复说明:
|
||||
// 旧实现使用第二个返回值 []TaskClassItem,只有 EmbeddedTime != nil 的条目(嵌入水课)才生成
|
||||
// placement,普通时段放置的任务全部被丢弃。
|
||||
// 正确做法:使用第一个返回值 []HybridScheduleEntry,过滤 Status="suggested" 且 TaskItemID>0 的条目,
|
||||
// 这样嵌入和非嵌入的粗排结果都能正确写入 ScheduleState。
|
||||
// adjustAgentRequestTokenUsage 负责把本轮 graph 的请求级 token 一次性回写到账本。
|
||||
//
|
||||
// 说明:
|
||||
// 1. agent 逐条可见消息都按 0 token 落库,最终统一在这里补记整轮消耗;
|
||||
// 2. 如果启用了 outbox,就沿用异步 token 调整事件,保持写账口径一致;
|
||||
// 3. 该步骤属于请求收尾,不应反过来打断用户已看到的回复。
|
||||
func (s *AgentService) adjustAgentRequestTokenUsage(ctx context.Context, userID int, chatID string, deltaTokens int) {
|
||||
if s == nil || userID <= 0 || strings.TrimSpace(chatID) == "" || deltaTokens <= 0 {
|
||||
return
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
if s.eventPublisher != nil {
|
||||
if err := eventsvc.PublishChatTokenUsageAdjustRequested(ctx, s.eventPublisher, model.ChatTokenUsageAdjustPayload{
|
||||
UserID: userID,
|
||||
ConversationID: chatID,
|
||||
TokensDelta: deltaTokens,
|
||||
Reason: "new_agent_request",
|
||||
TriggeredAt: time.Now(),
|
||||
}); err != nil {
|
||||
log.Printf("写入 agent 请求级 token 调整事件失败 chat=%s tokens=%d err=%v", chatID, deltaTokens, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.repo.AdjustTokenUsage(ctx, userID, chatID, deltaTokens, ""); err != nil {
|
||||
log.Printf("同步写入 agent 请求级 token 调整失败 chat=%s tokens=%d err=%v", chatID, deltaTokens, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AgentService) makeRoughBuildFunc() agentmodel.RoughBuildFunc {
|
||||
if s.HybridScheduleWithPlanMultiFunc == nil {
|
||||
return nil
|
||||
}
|
||||
return func(ctx context.Context, userID int, taskClassIDs []int) ([]agentmodel.RoughBuildPlacement, error) {
|
||||
entries, _, err := s.HybridScheduleWithPlanMultiFunc(ctx, userID, taskClassIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
placements := make([]agentmodel.RoughBuildPlacement, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.Status != "suggested" || entry.TaskItemID == 0 {
|
||||
continue
|
||||
}
|
||||
placements = append(placements, agentmodel.RoughBuildPlacement{
|
||||
TaskItemID: entry.TaskItemID,
|
||||
Week: entry.Week,
|
||||
DayOfWeek: entry.DayOfWeek,
|
||||
SectionFrom: entry.SectionFrom,
|
||||
SectionTo: entry.SectionTo,
|
||||
})
|
||||
}
|
||||
return placements, nil
|
||||
}
|
||||
}
|
||||
|
||||
// makeWriteSchedulePreviewFunc 封装 cacheDAO 写排程预览缓存的操作,供 Execute/Deliver 节点复用。
|
||||
func (s *AgentService) makeWriteSchedulePreviewFunc() agentmodel.WriteSchedulePreviewFunc {
|
||||
if s.cacheDAO == nil {
|
||||
return nil
|
||||
}
|
||||
return func(ctx context.Context, state *schedule.ScheduleState, userID int, conversationID string, taskClassIDs []int) error {
|
||||
stateDigest := summarizeScheduleStateForPreviewDebug(state)
|
||||
preview := agentconv.ScheduleStateToPreview(state, userID, conversationID, taskClassIDs, "")
|
||||
if preview == nil {
|
||||
log.Printf("[WARN] schedule preview skipped chat=%s user=%d state=%s", conversationID, userID, stateDigest)
|
||||
return nil
|
||||
}
|
||||
previewDigest := summarizeHybridEntriesForPreviewDebug(preview.HybridEntries)
|
||||
log.Printf(
|
||||
"[DEBUG] schedule preview write chat=%s user=%d state=%s preview=%s generated_at=%s",
|
||||
conversationID,
|
||||
userID,
|
||||
stateDigest,
|
||||
previewDigest,
|
||||
preview.GeneratedAt.Format(time.RFC3339),
|
||||
)
|
||||
return s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, conversationID, preview)
|
||||
}
|
||||
}
|
||||
|
||||
// summarizeScheduleStateForPreviewDebug 统计 Deliver 写预览前的内存日程摘要。
|
||||
func summarizeScheduleStateForPreviewDebug(state *schedule.ScheduleState) string {
|
||||
if state == nil {
|
||||
return "state=nil"
|
||||
}
|
||||
|
||||
total := len(state.Tasks)
|
||||
pendingTotal := 0
|
||||
suggestedTotal := 0
|
||||
existingTotal := 0
|
||||
taskItemWithSlot := 0
|
||||
eventWithSlot := 0
|
||||
for i := range state.Tasks {
|
||||
t := &state.Tasks[i]
|
||||
hasSlot := len(t.Slots) > 0
|
||||
|
||||
switch {
|
||||
case schedule.IsPendingTask(*t):
|
||||
pendingTotal++
|
||||
case schedule.IsSuggestedTask(*t):
|
||||
suggestedTotal++
|
||||
case schedule.IsExistingTask(*t):
|
||||
existingTotal++
|
||||
}
|
||||
if hasSlot {
|
||||
if t.Source == "task_item" {
|
||||
taskItemWithSlot++
|
||||
}
|
||||
if t.Source == "event" {
|
||||
eventWithSlot++
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"tasks=%d pending=%d suggested=%d existing=%d task_item_with_slot=%d event_with_slot=%d",
|
||||
total,
|
||||
pendingTotal,
|
||||
suggestedTotal,
|
||||
existingTotal,
|
||||
taskItemWithSlot,
|
||||
eventWithSlot,
|
||||
)
|
||||
}
|
||||
|
||||
// summarizeHybridEntriesForPreviewDebug 统计预览转换后的 HybridEntries 摘要。
|
||||
func summarizeHybridEntriesForPreviewDebug(entries []model.HybridScheduleEntry) string {
|
||||
existing := 0
|
||||
suggested := 0
|
||||
taskType := 0
|
||||
courseType := 0
|
||||
for _, e := range entries {
|
||||
if e.Status == "suggested" {
|
||||
suggested++
|
||||
} else {
|
||||
existing++
|
||||
}
|
||||
if e.Type == "task" {
|
||||
taskType++
|
||||
}
|
||||
if e.Type == "course" {
|
||||
courseType++
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"entries=%d existing=%d suggested=%d task_type=%d course_type=%d",
|
||||
len(entries),
|
||||
existing,
|
||||
suggested,
|
||||
taskType,
|
||||
courseType,
|
||||
)
|
||||
}
|
||||
|
||||
// --- 依赖注入字段 ---
|
||||
|
||||
// toolRegistry 由 cmd/start.go 注入
|
||||
func (s *AgentService) SetToolRegistry(registry *agenttools.ToolRegistry) {
|
||||
s.toolRegistry = registry
|
||||
}
|
||||
|
||||
// scheduleProvider 由 cmd/start.go 注入
|
||||
func (s *AgentService) SetScheduleProvider(provider agentmodel.ScheduleStateProvider) {
|
||||
s.scheduleProvider = provider
|
||||
}
|
||||
|
||||
// agentStateStore 由 cmd/start.go 注入
|
||||
func (s *AgentService) SetAgentStateStore(store agentmodel.AgentStateStore) {
|
||||
s.agentStateStore = store
|
||||
}
|
||||
|
||||
// compactionStore 由 cmd/start.go 注入
|
||||
func (s *AgentService) SetCompactionStore(store agentmodel.CompactionStore) {
|
||||
s.compactionStore = store
|
||||
}
|
||||
|
||||
// quickTaskDeps 由 cmd/start.go 注入
|
||||
func (s *AgentService) SetQuickTaskDeps(deps agentmodel.QuickTaskDeps) {
|
||||
s.quickTaskDeps = deps
|
||||
}
|
||||
213
backend/services/agent/sv/agent_memory.go
Normal file
213
backend/services/agent/sv/agent_memory.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
|
||||
memoryobserve "github.com/LoveLosita/smartflow/backend/memory/observe"
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
)
|
||||
|
||||
const (
|
||||
agentMemoryRetrieveLimit = 10
|
||||
agentMemoryIntroLine = "以下是与当前对话相关的用户记忆,仅在自然且确实有帮助时参考,不要生硬复述。"
|
||||
)
|
||||
|
||||
// MemoryReader 描述 agent 主链路读取记忆所需的最小能力。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责"按当前输入取回候选记忆";
|
||||
// 2. 不负责 prompt 拼装,也不要求调用方感知 memory 模块内部 repo/service 结构;
|
||||
// 3. 返回值直接复用 memory DTO,避免 service 层再维护一套重复结构。
|
||||
type MemoryReader interface {
|
||||
Retrieve(ctx context.Context, req memorymodel.RetrieveRequest) ([]memorymodel.ItemDTO, error)
|
||||
}
|
||||
|
||||
type memoryObserveProvider interface {
|
||||
MemoryObserver() memoryobserve.Observer
|
||||
MemoryMetrics() memoryobserve.MetricsRecorder
|
||||
}
|
||||
|
||||
// SetMemoryReader 注入 agent 主链路读取记忆所需的薄接口与渲染配置。
|
||||
func (s *AgentService) SetMemoryReader(reader MemoryReader, cfg memorymodel.Config) {
|
||||
s.memoryReader = reader
|
||||
s.memoryCfg = cfg
|
||||
s.memoryObserver = memoryobserve.NewNopObserver()
|
||||
s.memoryMetrics = memoryobserve.NewNopMetrics()
|
||||
if provider, ok := reader.(memoryObserveProvider); ok {
|
||||
s.memoryObserver = provider.MemoryObserver()
|
||||
s.memoryMetrics = provider.MemoryMetrics()
|
||||
}
|
||||
}
|
||||
|
||||
// injectMemoryContext 在 graph 执行前,把本轮相关记忆写入 ConversationContext 的 pinned block。
|
||||
//
|
||||
// 改造后采用"预取管线"模式:
|
||||
// 1. 先读 Redis 预取缓存(上一轮写入),命中则立即注入到 ConversationContext;
|
||||
// 2. 再启动后台 goroutine 做完整记忆检索,渲染后发到 channel + 写 Redis;
|
||||
// 3. Chat 节点直接用缓存记忆启动(首字节零延迟),Execute/Plan 通过 channel 消费最新结果。
|
||||
func (s *AgentService) injectMemoryContext(
|
||||
ctx context.Context,
|
||||
conversationContext *agentmodel.ConversationContext,
|
||||
userID int,
|
||||
chatID string,
|
||||
userMessage string,
|
||||
) chan string {
|
||||
memoryFuture := make(chan string, 1)
|
||||
|
||||
if conversationContext == nil {
|
||||
return memoryFuture
|
||||
}
|
||||
|
||||
// 1. 门控检查:无 reader 或无效用户时清掉旧 block 并返回空 channel。
|
||||
if s.memoryReader == nil || userID <= 0 {
|
||||
conversationContext.RemovePinnedBlock(agentmodel.MemoryContextBlockKey)
|
||||
return memoryFuture
|
||||
}
|
||||
|
||||
// 2. 读 Redis 预取缓存(<5ms),命中则注入。
|
||||
cachedItems, _ := s.cacheDAO.GetMemoryPrefetchCache(ctx, userID, chatID)
|
||||
if len(cachedItems) > 0 {
|
||||
content := renderMemoryPinnedContentByMode(cachedItems, s.memoryCfg.EffectiveInjectRenderMode())
|
||||
if content != "" {
|
||||
conversationContext.UpsertPinnedBlock(agentmodel.ContextBlock{
|
||||
Key: agentmodel.MemoryContextBlockKey,
|
||||
Title: agentmodel.MemoryContextBlockTitle,
|
||||
Content: content,
|
||||
})
|
||||
s.recordMemoryInject(ctx, userID, len(cachedItems), true, nil, "prefetch_cache")
|
||||
log.Printf("[INFO] memory prefetch: 从 Redis 缓存注入记忆 user=%d count=%d", userID, len(cachedItems))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 短应答不启动后台检索,节省资源。
|
||||
if !shouldInjectMemoryForInput(userMessage) {
|
||||
log.Printf("[INFO] memory prefetch: 短应答跳过检索 user=%d msg=%q", userID, userMessage)
|
||||
return memoryFuture
|
||||
}
|
||||
|
||||
// 4. 启动后台 goroutine:完整检索 → 渲染 → 发 channel + 写 Redis。
|
||||
log.Printf("[INFO] memory prefetch: 启动后台检索 goroutine user=%d chat=%s", userID, chatID)
|
||||
go s.prefetchMemoryForNextTurn(userID, chatID, userMessage, memoryFuture)
|
||||
|
||||
return memoryFuture
|
||||
}
|
||||
|
||||
// prefetchMemoryForNextTurn 后台执行完整记忆检索,将结果渲染后发送到 channel 并写入 Redis。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 检索结果渲染为文本后发送到 memoryFuture channel(供 Execute/Plan 节点消费);
|
||||
// 2. 原始 ItemDTO 写入 Redis 预取缓存(供下一轮 Chat 节点消费);
|
||||
// 3. 检索失败只记日志,不阻断主链路。
|
||||
func (s *AgentService) prefetchMemoryForNextTurn(userID int, chatID, userMessage string, memoryFuture chan string) {
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
items, err := s.memoryReader.Retrieve(bgCtx, memorymodel.RetrieveRequest{
|
||||
Query: strings.TrimSpace(userMessage),
|
||||
UserID: userID,
|
||||
ConversationID: strings.TrimSpace(chatID),
|
||||
Limit: agentMemoryRetrieveLimit,
|
||||
Now: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[WARN] 记忆预取失败 user=%d chat=%s: %v", userID, chatID, err)
|
||||
s.recordMemoryInject(bgCtx, userID, 0, false, err, "prefetch_retrieve")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[INFO] memory prefetch: 后台检索完成 user=%d count=%d", userID, len(items))
|
||||
|
||||
if len(items) == 0 {
|
||||
// 1. 检索为空说明该用户当前没有可用记忆,旧缓存已过期;
|
||||
// 2. 主动清除该用户所有会话的预取缓存,避免过期记忆在下一轮继续注入;
|
||||
// 3. 清除失败只记日志,不阻断主链路,缓存自然过期也可兜底。
|
||||
if cacheErr := s.cacheDAO.DeleteMemoryPrefetchCacheByUser(context.Background(), userID); cacheErr != nil {
|
||||
log.Printf("[WARN] memory prefetch cache clear failed (empty result) user=%d: %v", userID, cacheErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 渲染并发送到 channel(供 Execute/Plan 节点消费)。
|
||||
content := renderMemoryPinnedContentByMode(items, s.memoryCfg.EffectiveInjectRenderMode())
|
||||
if content != "" {
|
||||
memoryFuture <- content
|
||||
}
|
||||
|
||||
// 同时写入 Redis 供下一轮 Chat 使用。
|
||||
if cacheErr := s.cacheDAO.SetMemoryPrefetchCache(context.Background(), userID, chatID, items); cacheErr != nil {
|
||||
log.Printf("[WARN] 记忆预取缓存写入失败 user=%d: %v", userID, cacheErr)
|
||||
}
|
||||
}
|
||||
|
||||
// shouldInjectMemoryForInput 判断当前输入是否值得触发一次记忆召回。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 空输入直接跳过;
|
||||
// 2. 对"好/确认/ok"这类弱语义应答做显式拦截,避免 legacy fallback 在无查询价值时注入一批高分但不相关的旧记忆;
|
||||
// 3. 其余输入一律放行,优先保证 MVP 可用。
|
||||
func shouldInjectMemoryForInput(userMessage string) bool {
|
||||
trimmed := strings.TrimSpace(userMessage)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
switch strings.ToLower(trimmed) {
|
||||
case "好", "好的", "嗯", "嗯嗯", "行", "可以", "收到", "明白", "确认", "取消", "是", "不是", "对", "不对", "ok", "okay", "yes", "no":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AgentService) recordMemoryInject(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
inputCount int,
|
||||
success bool,
|
||||
err error,
|
||||
source string,
|
||||
) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
observer := s.memoryObserver
|
||||
if observer == nil {
|
||||
observer = memoryobserve.NewNopObserver()
|
||||
}
|
||||
metrics := s.memoryMetrics
|
||||
if metrics == nil {
|
||||
metrics = memoryobserve.NewNopMetrics()
|
||||
}
|
||||
|
||||
level := memoryobserve.LevelInfo
|
||||
if err != nil {
|
||||
level = memoryobserve.LevelWarn
|
||||
}
|
||||
observer.Observe(ctx, memoryobserve.Event{
|
||||
Level: level,
|
||||
Component: memoryobserve.ComponentInject,
|
||||
Operation: memoryobserve.OperationInject,
|
||||
Fields: map[string]any{
|
||||
"user_id": userID,
|
||||
"inject_mode": s.memoryCfg.EffectiveInjectRenderMode(),
|
||||
"input_count": inputCount,
|
||||
"rendered_count": inputCount,
|
||||
"token_budget": 0,
|
||||
"fallback": false,
|
||||
"success": success && err == nil,
|
||||
"error": err,
|
||||
"error_code": memoryobserve.ClassifyError(err),
|
||||
"source": source,
|
||||
},
|
||||
})
|
||||
if inputCount > 0 {
|
||||
metrics.AddCounter(memoryobserve.MetricInjectItemTotal, int64(inputCount), map[string]string{
|
||||
"inject_mode": s.memoryCfg.EffectiveInjectRenderMode(),
|
||||
"source": source,
|
||||
})
|
||||
}
|
||||
}
|
||||
155
backend/services/agent/sv/agent_memory_render.go
Normal file
155
backend/services/agent/sv/agent_memory_render.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
|
||||
)
|
||||
|
||||
// renderMemoryPinnedContentByMode 根据配置选择记忆渲染方式。
|
||||
func renderMemoryPinnedContentByMode(items []memorymodel.ItemDTO, renderMode string) string {
|
||||
switch memorymodel.NormalizeInjectRenderMode(renderMode) {
|
||||
case memorymodel.MemoryInjectRenderModeTypedV2:
|
||||
return RenderTypedMemoryContent(items)
|
||||
default:
|
||||
return RenderFlatMemoryContent(items)
|
||||
}
|
||||
}
|
||||
|
||||
// RenderFlatMemoryContent 生成兼容旧链路的扁平记忆文本。
|
||||
func RenderFlatMemoryContent(items []memorymodel.ItemDTO) string {
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(agentMemoryIntroLine)
|
||||
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
written := 0
|
||||
for _, item := range items {
|
||||
line := buildMemoryPinnedLine(item)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[line]; exists {
|
||||
continue
|
||||
}
|
||||
seen[line] = struct{}{}
|
||||
sb.WriteString("\n- ")
|
||||
sb.WriteString(line)
|
||||
written++
|
||||
}
|
||||
|
||||
if written == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// RenderTypedMemoryContent 按记忆类型分段渲染。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先按固定类型顺序分组,避免同类记忆在 prompt 中被打散;
|
||||
// 2. 每组内部继续做文本级去重,兜底保护历史脏数据;
|
||||
// 3. 只输出非空分组,减少 Execute / Plan 阶段的无效噪音。
|
||||
func RenderTypedMemoryContent(items []memorymodel.ItemDTO) string {
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
type renderSection struct {
|
||||
Title string
|
||||
Items []string
|
||||
}
|
||||
orderedTypes := []string{
|
||||
memorymodel.MemoryTypeConstraint,
|
||||
memorymodel.MemoryTypePreference,
|
||||
memorymodel.MemoryTypeFact,
|
||||
}
|
||||
sectionTitle := map[string]string{
|
||||
memorymodel.MemoryTypeConstraint: "必守约束",
|
||||
memorymodel.MemoryTypePreference: "用户偏好",
|
||||
memorymodel.MemoryTypeFact: "当前话题相关事实",
|
||||
}
|
||||
|
||||
grouped := make(map[string][]string, len(orderedTypes))
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
for _, item := range items {
|
||||
content := buildMemoryRenderContent(item)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
dedupKey := strings.TrimSpace(item.MemoryType) + "::" + content
|
||||
if _, exists := seen[dedupKey]; exists {
|
||||
continue
|
||||
}
|
||||
seen[dedupKey] = struct{}{}
|
||||
|
||||
memoryType := memorymodel.NormalizeMemoryType(item.MemoryType)
|
||||
if memoryType == "" {
|
||||
memoryType = memorymodel.MemoryTypeFact
|
||||
}
|
||||
grouped[memoryType] = append(grouped[memoryType], content)
|
||||
}
|
||||
|
||||
sections := make([]renderSection, 0, len(orderedTypes))
|
||||
for _, memoryType := range orderedTypes {
|
||||
contentList := grouped[memoryType]
|
||||
if len(contentList) == 0 {
|
||||
continue
|
||||
}
|
||||
sections = append(sections, renderSection{
|
||||
Title: sectionTitle[memoryType],
|
||||
Items: contentList,
|
||||
})
|
||||
}
|
||||
if len(sections) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(agentMemoryIntroLine)
|
||||
for _, section := range sections {
|
||||
sb.WriteString("\n\n【")
|
||||
sb.WriteString(section.Title)
|
||||
sb.WriteString("】")
|
||||
for _, line := range section.Items {
|
||||
sb.WriteString("\n- ")
|
||||
sb.WriteString(line)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// buildMemoryPinnedLine 把单条记忆渲染成“[类型] 内容”的简洁格式。
|
||||
func buildMemoryPinnedLine(item memorymodel.ItemDTO) string {
|
||||
text := buildMemoryRenderContent(item)
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("[%s] %s", localizeMemoryType(item.MemoryType), text)
|
||||
}
|
||||
|
||||
func buildMemoryRenderContent(item memorymodel.ItemDTO) string {
|
||||
text := strings.TrimSpace(item.Content)
|
||||
if text == "" {
|
||||
text = strings.TrimSpace(item.Title)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// localizeMemoryType 把 memory 类型映射成 prompt 里更自然的中文标签。
|
||||
func localizeMemoryType(memoryType string) string {
|
||||
switch strings.TrimSpace(memoryType) {
|
||||
case memorymodel.MemoryTypePreference:
|
||||
return "偏好"
|
||||
case memorymodel.MemoryTypeConstraint:
|
||||
return "约束"
|
||||
case memorymodel.MemoryTypeFact:
|
||||
return "事实"
|
||||
default:
|
||||
return "记忆"
|
||||
}
|
||||
}
|
||||
354
backend/services/agent/sv/agent_meta.go
Normal file
354
backend/services/agent/sv/agent_meta.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
eventsvc "github.com/LoveLosita/smartflow/backend/service/events"
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
// conversationTitleTimeout 是异步标题生成的超时时间。
|
||||
// 该过程不在主请求链路里,但仍要设置上限,避免后台协程长时间阻塞。
|
||||
conversationTitleTimeout = 4 * time.Second
|
||||
// conversationTitleHistoryLimit 限制参与“生成标题”的最近消息条数。
|
||||
// 只取最近几轮可减少 token 成本,同时足够概括当前会话主题。
|
||||
conversationTitleHistoryLimit = 8
|
||||
// conversationTitleMaxChars 是标题最大字符数(按 rune 计)。
|
||||
// 控制标题长度,避免前端展示溢出。
|
||||
conversationTitleMaxChars = 24
|
||||
// conversationListDefaultPage 是会话列表默认页码。
|
||||
conversationListDefaultPage = 1
|
||||
// conversationListDefaultPageSize 是会话列表默认分页大小。
|
||||
conversationListDefaultPageSize = 20
|
||||
// conversationListMaxPageSize 是会话列表单页上限,避免超大分页压垮数据库。
|
||||
conversationListMaxPageSize = 100
|
||||
// conversationTitleTokenAdjustReason 是“标题异步生成 token 账本调整”原因码。
|
||||
// 用于日志和后续审计归因。
|
||||
conversationTitleTokenAdjustReason = "conversation_title_async"
|
||||
)
|
||||
|
||||
const conversationTitlePrompt = `你是 SmartMate 的会话标题生成器。
|
||||
请基于给定对话内容,生成一个简短中文标题。
|
||||
|
||||
要求:
|
||||
1) 只输出标题文本,不要解释,不要加引号,不要 markdown。
|
||||
2) 标题长度控制在 8~20 个中文字符,尽量自然、口语化。
|
||||
3) 不要出现“用户/助手/对话/聊天记录”等泛化词。
|
||||
4) 如果内容是任务提醒类,标题应体现核心事项。`
|
||||
|
||||
// GetConversationMeta 返回单个会话的元信息(供前端轮询/主动拉取)。
|
||||
// 说明:
|
||||
// 1) 该接口和 SSE 流解耦,不依赖流式 header;
|
||||
// 2) title 允许为空,前端可根据 has_title 决定是否展示占位文案。
|
||||
func (s *AgentService) GetConversationMeta(ctx context.Context, userID int, chatID string) (*model.GetConversationMetaResponse, error) {
|
||||
chat, err := s.repo.GetConversationMeta(ctx, userID, strings.TrimSpace(chatID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
title := ""
|
||||
if chat.Title != nil {
|
||||
title = strings.TrimSpace(*chat.Title)
|
||||
}
|
||||
|
||||
return &model.GetConversationMetaResponse{
|
||||
ConversationID: chat.ChatID,
|
||||
Title: title,
|
||||
HasTitle: title != "",
|
||||
MessageCount: chat.MessageCount,
|
||||
LastMessageAt: chat.LastMessageAt,
|
||||
Status: chat.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetConversationList 返回“当前用户会话列表(分页)”。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责分页参数规范化(默认值、上限保护);
|
||||
// 2. 负责状态过滤值校验(仅允许 active/archived);
|
||||
// 3. 负责把 DAO 模型转换成前端响应 DTO;
|
||||
// 4. 不负责缓存(由上层架构决策按需引入)。
|
||||
func (s *AgentService) GetConversationList(ctx context.Context, userID, page, pageSize int, status string) (*model.GetConversationListResponse, error) {
|
||||
// 1. 先做参数规范化,保证 DAO 层始终收到安全参数。
|
||||
normalizedPage := normalizeConversationListPage(page)
|
||||
normalizedPageSize := normalizeConversationListPageSize(pageSize)
|
||||
|
||||
// 2. 校验状态过滤器:
|
||||
// 2.1 允许空值(表示不过滤);
|
||||
// 2.2 仅接受 active/archived,避免把任意字符串下推到 SQL。
|
||||
normalizedStatus, valid := normalizeConversationStatus(status)
|
||||
if !valid {
|
||||
return nil, respond.WrongParamType
|
||||
}
|
||||
|
||||
// 3. 查库拿分页结果。
|
||||
chats, total, err := s.repo.GetConversationList(ctx, userID, normalizedPage, normalizedPageSize, normalizedStatus)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4. 转换为响应 DTO,统一 title/has_title 语义,避免前端重复处理空指针。
|
||||
items := make([]model.GetConversationListItem, 0, len(chats))
|
||||
for _, chatItem := range chats {
|
||||
title := ""
|
||||
if chatItem.Title != nil {
|
||||
title = strings.TrimSpace(*chatItem.Title)
|
||||
}
|
||||
items = append(items, model.GetConversationListItem{
|
||||
ConversationID: chatItem.ChatID,
|
||||
Title: title,
|
||||
HasTitle: title != "",
|
||||
MessageCount: chatItem.MessageCount,
|
||||
LastMessageAt: chatItem.LastMessageAt,
|
||||
Status: chatItem.Status,
|
||||
CreatedAt: chatItem.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// 5. 计算 has_more 语义,前端可直接用于“继续加载”按钮。
|
||||
hasMore := int64(normalizedPage*normalizedPageSize) < total
|
||||
return &model.GetConversationListResponse{
|
||||
List: items,
|
||||
Page: normalizedPage,
|
||||
PageSize: normalizedPageSize,
|
||||
Limit: normalizedPageSize,
|
||||
Total: total,
|
||||
HasMore: hasMore,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeConversationListPage(page int) int {
|
||||
if page <= 0 {
|
||||
return conversationListDefaultPage
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
func normalizeConversationListPageSize(pageSize int) int {
|
||||
if pageSize <= 0 {
|
||||
return conversationListDefaultPageSize
|
||||
}
|
||||
if pageSize > conversationListMaxPageSize {
|
||||
return conversationListMaxPageSize
|
||||
}
|
||||
return pageSize
|
||||
}
|
||||
|
||||
func normalizeConversationStatus(status string) (string, bool) {
|
||||
normalized := strings.TrimSpace(strings.ToLower(status))
|
||||
if normalized == "" {
|
||||
return "", true
|
||||
}
|
||||
if normalized == "active" || normalized == "archived" {
|
||||
return normalized, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ensureConversationTitleAsync 在后台异步生成并写入会话标题。
|
||||
// 设计约束:
|
||||
// 1) 仅在“标题为空”时尝试生成,避免覆盖用户已确认/已存在标题;
|
||||
// 2) 失败只记日志,不影响当前聊天链路;
|
||||
// 3) 标题素材优先来自 Redis 历史(命中快、与当前上下文一致)。
|
||||
func (s *AgentService) ensureConversationTitleAsync(userID int, chatID string) {
|
||||
if s == nil || s.repo == nil || s.agentCache == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(chatID) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
// 1. 后台任务使用独立超时上下文,避免受请求 ctx 取消影响。
|
||||
ctx, cancel := context.WithTimeout(context.Background(), conversationTitleTimeout)
|
||||
defer cancel()
|
||||
|
||||
// 2. 先查当前标题;若已存在则直接返回,不做多余模型调用。
|
||||
title, exists, err := s.repo.GetConversationTitle(ctx, userID, chatID)
|
||||
if err != nil {
|
||||
log.Printf("异步生成会话标题失败(读取标题失败) chat=%s err=%v", chatID, err)
|
||||
return
|
||||
}
|
||||
if !exists || strings.TrimSpace(title) != "" {
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 从 Redis 读取当前会话历史,作为标题生成素材。
|
||||
history, err := s.agentCache.GetHistory(ctx, chatID)
|
||||
if err != nil {
|
||||
log.Printf("异步生成会话标题失败(读取历史失败) chat=%s err=%v", chatID, err)
|
||||
return
|
||||
}
|
||||
if len(history) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 调用模型生成标题,并做格式清洗。
|
||||
generated, titleTokens, err := s.generateConversationTitle(ctx, history)
|
||||
if err != nil {
|
||||
log.Printf("异步生成会话标题失败(模型生成失败) chat=%s err=%v", chatID, err)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(generated) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// 4.1 标题生成成功后,把本次异步模型 token 记账:
|
||||
// 4.1.1 启用 outbox 时走 adjust 事件,异步可靠入账;
|
||||
// 4.1.2 未启用 outbox 时走同步兜底,直接更新账本。
|
||||
if titleTokens > 0 {
|
||||
if s.eventPublisher != nil {
|
||||
publishErr := eventsvc.PublishChatTokenUsageAdjustRequested(ctx, s.eventPublisher, model.ChatTokenUsageAdjustPayload{
|
||||
UserID: userID,
|
||||
ConversationID: chatID,
|
||||
TokensDelta: titleTokens,
|
||||
Reason: conversationTitleTokenAdjustReason,
|
||||
TriggeredAt: time.Now(),
|
||||
})
|
||||
if publishErr != nil {
|
||||
log.Printf("异步标题 token 记账事件发布失败 chat=%s tokens=%d err=%v", chatID, titleTokens, publishErr)
|
||||
}
|
||||
} else {
|
||||
if adjustErr := s.repo.AdjustTokenUsage(ctx, userID, chatID, titleTokens, ""); adjustErr != nil {
|
||||
log.Printf("异步标题 token 同步记账失败 chat=%s tokens=%d err=%v", chatID, titleTokens, adjustErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 只在标题仍为空时写入,保证并发幂等。
|
||||
if err = s.repo.UpdateConversationTitleIfEmpty(ctx, userID, chatID, generated); err != nil {
|
||||
log.Printf("异步生成会话标题失败(写库失败) chat=%s err=%v", chatID, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// generateConversationTitle 使用聊天模型从近期历史生成标题。
|
||||
func (s *AgentService) generateConversationTitle(ctx context.Context, history []*schema.Message) (string, int, error) {
|
||||
modelInst := s.pickTitleModel()
|
||||
if modelInst == nil {
|
||||
return "", 0, fmt.Errorf("标题生成模型未初始化")
|
||||
}
|
||||
|
||||
// 1. 只取最近 N 条,降低 token 并聚焦当前会话主题。
|
||||
trimmed := tailMessages(history, conversationTitleHistoryLimit)
|
||||
prompt := buildConversationTitleUserPrompt(trimmed)
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
return "", 0, fmt.Errorf("缺少可用历史内容")
|
||||
}
|
||||
|
||||
messages := []*schema.Message{
|
||||
schema.SystemMessage(conversationTitlePrompt),
|
||||
schema.UserMessage(prompt),
|
||||
}
|
||||
|
||||
// 2. 标题生成属于结构化短输出,关闭 thinking 并限制 tokens,降低延迟与发散。
|
||||
resp, err := modelInst.GenerateText(ctx, messages, llmservice.GenerateOptions{
|
||||
Temperature: 0.2,
|
||||
MaxTokens: 40,
|
||||
Thinking: llmservice.ThinkingModeDisabled,
|
||||
})
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if resp == nil {
|
||||
return "", 0, fmt.Errorf("标题生成模型返回为空")
|
||||
}
|
||||
|
||||
// 2.1 标题链路的 token 从模型响应 usage 中提取;缺失则按 0 处理,不影响主流程。
|
||||
titleTokens := 0
|
||||
if resp.Usage != nil {
|
||||
titleTokens = normalizeUsageTotal(
|
||||
resp.Usage.TotalTokens,
|
||||
resp.Usage.PromptTokens,
|
||||
resp.Usage.CompletionTokens,
|
||||
)
|
||||
}
|
||||
return normalizeConversationTitle(resp.Text), titleTokens, nil
|
||||
}
|
||||
|
||||
// pickTitleModel 选择用于标题生成的模型。
|
||||
// 优先 Lite(成本低、速度快);Lite 不可用时回退 Pro。
|
||||
func (s *AgentService) pickTitleModel() *llmservice.Client {
|
||||
if s == nil || s.llmService == nil {
|
||||
return nil
|
||||
}
|
||||
if client := s.llmService.LiteClient(); client != nil {
|
||||
return client
|
||||
}
|
||||
return s.llmService.ProClient()
|
||||
}
|
||||
|
||||
// buildConversationTitleUserPrompt 把消息历史拼成可读文本供模型总结。
|
||||
func buildConversationTitleUserPrompt(messages []*schema.Message) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("请根据以下对话内容生成标题:\n")
|
||||
for _, msg := range messages {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
content := strings.TrimSpace(msg.Content)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
// 单条消息做长度裁剪,避免超长回复把标题主题“冲淡”。
|
||||
content = trimRunes(content, 80)
|
||||
role := "助手"
|
||||
if strings.EqualFold(strings.TrimSpace(string(msg.Role)), string(schema.User)) {
|
||||
role = "用户"
|
||||
}
|
||||
builder.WriteString(role)
|
||||
builder.WriteString(":")
|
||||
builder.WriteString(content)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
return strings.TrimSpace(builder.String())
|
||||
}
|
||||
|
||||
func tailMessages(messages []*schema.Message, limit int) []*schema.Message {
|
||||
if limit <= 0 || len(messages) <= limit {
|
||||
return messages
|
||||
}
|
||||
return messages[len(messages)-limit:]
|
||||
}
|
||||
|
||||
// normalizeConversationTitle 清洗模型输出,确保可直接展示/存库。
|
||||
func normalizeConversationTitle(raw string) string {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
if idx := strings.Index(text, "\n"); idx >= 0 {
|
||||
text = strings.TrimSpace(text[:idx])
|
||||
}
|
||||
text = strings.Trim(text, "\"'“”‘’《》[]【】")
|
||||
text = strings.TrimPrefix(text, "标题:")
|
||||
text = strings.TrimPrefix(text, "标题:")
|
||||
text = strings.TrimSpace(text)
|
||||
text = trimRunes(text, conversationTitleMaxChars)
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func trimRunes(text string, limit int) string {
|
||||
if limit <= 0 || text == "" {
|
||||
return ""
|
||||
}
|
||||
if utf8.RuneCountInString(text) <= limit {
|
||||
return text
|
||||
}
|
||||
runes := []rune(text)
|
||||
return string(runes[:limit])
|
||||
}
|
||||
|
||||
// GetContextStats 获取指定会话的上下文窗口 token 分布统计。
|
||||
func (s *AgentService) GetContextStats(ctx context.Context, userID int, chatID string) (string, error) {
|
||||
return s.repo.LoadContextTokenStats(ctx, userID, chatID)
|
||||
}
|
||||
130
backend/services/agent/sv/agent_schedule_preview.go
Normal file
130
backend/services/agent/sv/agent_schedule_preview.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
agentshared "github.com/LoveLosita/smartflow/backend/services/agent/shared"
|
||||
)
|
||||
|
||||
// GetSchedulePlanPreview 按 conversation_id 读取结构化排程预览。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责参数归一化、缓存优先读取、会话归属校验和 DB 兜底。
|
||||
// 2. 负责把缓存/快照 DTO 转成接口响应 DTO。
|
||||
// 3. 不负责触发排程,不负责补算结果,也不负责消息链路落库。
|
||||
func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, chatID string) (*model.GetSchedulePlanPreviewResponse, error) {
|
||||
// 1. 先校验会话参数,避免无效请求打到缓存或数据库。
|
||||
normalizedChatID := strings.TrimSpace(chatID)
|
||||
if normalizedChatID == "" {
|
||||
return nil, respond.MissingParam
|
||||
}
|
||||
if s == nil {
|
||||
return nil, errors.New("agent service is not initialized")
|
||||
}
|
||||
|
||||
// 2. 优先查 Redis。
|
||||
if s.cacheDAO != nil {
|
||||
preview, err := s.cacheDAO.GetSchedulePlanPreviewFromCache(ctx, userID, normalizedChatID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if preview != nil {
|
||||
if preview.UserID > 0 && preview.UserID != userID {
|
||||
return nil, respond.SchedulePlanPreviewNotFound
|
||||
}
|
||||
plans := agentshared.CloneWeekSchedules(preview.CandidatePlans)
|
||||
if plans == nil {
|
||||
plans = make([]model.UserWeekSchedule, 0)
|
||||
}
|
||||
return &model.GetSchedulePlanPreviewResponse{
|
||||
ConversationID: normalizedChatID,
|
||||
TraceID: strings.TrimSpace(preview.TraceID),
|
||||
Summary: strings.TrimSpace(preview.Summary),
|
||||
CandidatePlans: plans,
|
||||
HybridEntries: agentshared.CloneHybridEntries(preview.HybridEntries),
|
||||
TaskClassIDs: preview.TaskClassIDs,
|
||||
GeneratedAt: preview.GeneratedAt,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Redis 未命中时回源 MySQL。
|
||||
if s.repo != nil {
|
||||
snapshot, err := s.repo.GetScheduleStateSnapshot(ctx, userID, normalizedChatID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if snapshot != nil {
|
||||
response := snapshotToSchedulePlanPreviewResponse(snapshot)
|
||||
if s.cacheDAO != nil {
|
||||
cachePreview := snapshotToSchedulePlanPreviewCache(snapshot)
|
||||
if setErr := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedChatID, cachePreview); setErr != nil {
|
||||
log.Printf("回填排程预览缓存失败 chat_id=%s: %v", normalizedChatID, setErr)
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, respond.SchedulePlanPreviewNotFound
|
||||
}
|
||||
|
||||
// snapshotToSchedulePlanPreviewCache 把 MySQL 快照映射成 Redis 预览缓存结构。
|
||||
func snapshotToSchedulePlanPreviewCache(snapshot *model.SchedulePlanStateSnapshot) *model.SchedulePlanPreviewCache {
|
||||
if snapshot == nil {
|
||||
return nil
|
||||
}
|
||||
generatedAt := snapshot.UpdatedAt
|
||||
if generatedAt.IsZero() {
|
||||
generatedAt = time.Now()
|
||||
}
|
||||
return &model.SchedulePlanPreviewCache{
|
||||
UserID: snapshot.UserID,
|
||||
ConversationID: snapshot.ConversationID,
|
||||
TraceID: strings.TrimSpace(snapshot.TraceID),
|
||||
Summary: schedulePlanSummaryOrFallback(strings.TrimSpace(snapshot.FinalSummary)),
|
||||
CandidatePlans: agentshared.CloneWeekSchedules(snapshot.CandidatePlans),
|
||||
TaskClassIDs: append([]int(nil), snapshot.TaskClassIDs...),
|
||||
HybridEntries: agentshared.CloneHybridEntries(snapshot.HybridEntries),
|
||||
AllocatedItems: agentshared.CloneTaskClassItems(snapshot.AllocatedItems),
|
||||
GeneratedAt: generatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// snapshotToSchedulePlanPreviewResponse 把 MySQL 快照映射成查询接口响应结构。
|
||||
func snapshotToSchedulePlanPreviewResponse(snapshot *model.SchedulePlanStateSnapshot) *model.GetSchedulePlanPreviewResponse {
|
||||
if snapshot == nil {
|
||||
return nil
|
||||
}
|
||||
plans := agentshared.CloneWeekSchedules(snapshot.CandidatePlans)
|
||||
if plans == nil {
|
||||
plans = make([]model.UserWeekSchedule, 0)
|
||||
}
|
||||
generatedAt := snapshot.UpdatedAt
|
||||
if generatedAt.IsZero() {
|
||||
generatedAt = time.Now()
|
||||
}
|
||||
return &model.GetSchedulePlanPreviewResponse{
|
||||
ConversationID: snapshot.ConversationID,
|
||||
TraceID: strings.TrimSpace(snapshot.TraceID),
|
||||
Summary: schedulePlanSummaryOrFallback(strings.TrimSpace(snapshot.FinalSummary)),
|
||||
CandidatePlans: plans,
|
||||
HybridEntries: agentshared.CloneHybridEntries(snapshot.HybridEntries),
|
||||
TaskClassIDs: snapshot.TaskClassIDs,
|
||||
GeneratedAt: generatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// schedulePlanSummaryOrFallback 统一收口排程摘要兜底文案,避免各处重复维护默认值。
|
||||
func schedulePlanSummaryOrFallback(summary string) string {
|
||||
if strings.TrimSpace(summary) == "" {
|
||||
return "排程流程已完成,但未生成结果摘要。"
|
||||
}
|
||||
return summary
|
||||
}
|
||||
143
backend/services/agent/sv/agent_schedule_state.go
Normal file
143
backend/services/agent/sv/agent_schedule_state.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
agentconv "github.com/LoveLosita/smartflow/backend/services/agent/conv"
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agentshared "github.com/LoveLosita/smartflow/backend/services/agent/shared"
|
||||
)
|
||||
|
||||
// SaveScheduleState 处理前端拖拽后的“暂存排程状态”请求。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把前端绝对坐标写回当前会话的 ScheduleState 快照;
|
||||
// 2. 负责刷新 Redis 预览缓存,保证后续预览读取与最新拖拽一致;
|
||||
// 3. 不负责写 MySQL 正式课表,也不负责触发新一轮 graph 执行。
|
||||
func (s *AgentService) SaveScheduleState(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
conversationID string,
|
||||
items []model.SaveScheduleStatePlacedItem,
|
||||
) error {
|
||||
// 1. 加载会话快照;没有快照说明当前会话不在可微调窗口内。
|
||||
if s.agentStateStore == nil {
|
||||
return errors.New("agent state store 未初始化")
|
||||
}
|
||||
snapshot, ok, err := s.agentStateStore.Load(ctx, conversationID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("加载快照失败: %w", err)
|
||||
}
|
||||
if !ok || snapshot == nil || snapshot.ScheduleState == nil {
|
||||
return respond.ScheduleStateSnapshotNotFound
|
||||
}
|
||||
|
||||
// 2. 做会话归属校验,防止跨用户写入别人的会话快照。
|
||||
if snapshot.RuntimeState != nil {
|
||||
cs := snapshot.RuntimeState.EnsureCommonState()
|
||||
if cs.UserID != 0 && cs.UserID != userID {
|
||||
return fmt.Errorf("会话归属校验失败:快照 user_id=%d,请求 user_id=%d", cs.UserID, userID)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 将前端绝对坐标应用到内存态 ScheduleState。
|
||||
// 3.1 这里只修改 source=task_item 任务;
|
||||
// 3.2 source=event 课程位保持不变;
|
||||
// 3.3 坐标非法时由 ApplyPlacedItems 返回明确错误。
|
||||
if err := agentconv.ApplyPlacedItems(snapshot.ScheduleState, items); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 4. 先写回运行态快照,确保“拖拽后的状态”成为后续读链路真值。
|
||||
if err := s.agentStateStore.Save(ctx, conversationID, snapshot); err != nil {
|
||||
return fmt.Errorf("保存快照失败: %w", err)
|
||||
}
|
||||
|
||||
// 5. 再刷新预览缓存,避免 GetSchedulePlanPreview 读到拖拽前旧缓存。
|
||||
if err := s.refreshSchedulePreviewAfterStateSave(ctx, userID, conversationID, snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("[INFO] schedule state saved chat=%s user=%d item_count=%d", conversationID, userID, len(items))
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshSchedulePreviewAfterStateSave 按“最新快照”重建并覆盖 Redis 预览缓存。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只处理 Redis 预览缓存,不负责 MySQL 快照;
|
||||
// 2. 以最新 ScheduleState 为准,修复“预览读到旧拖拽结果”的回滚问题;
|
||||
// 3. 尽量保留旧预览中的 trace_id/candidate_plans,避免前端字段突变。
|
||||
func (s *AgentService) refreshSchedulePreviewAfterStateSave(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
conversationID string,
|
||||
snapshot *agentmodel.AgentStateSnapshot,
|
||||
) error {
|
||||
// 1. 依赖不完整时直接跳过,避免写入不完整缓存。
|
||||
if s == nil || s.cacheDAO == nil || snapshot == nil || snapshot.ScheduleState == nil {
|
||||
return nil
|
||||
}
|
||||
normalizedConversationID := strings.TrimSpace(conversationID)
|
||||
if normalizedConversationID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 2. 从运行态提取 task_class_ids,保证预览过滤口径与会话一致。
|
||||
taskClassIDs := make([]int, 0)
|
||||
if snapshot.RuntimeState != nil {
|
||||
flowState := snapshot.RuntimeState.EnsureCommonState()
|
||||
taskClassIDs = append(taskClassIDs, flowState.TaskClassIDs...)
|
||||
}
|
||||
|
||||
// 3. 基于最新 ScheduleState 生成预览主干(hybrid_entries 为最新真值)。
|
||||
preview := agentconv.ScheduleStateToPreview(
|
||||
snapshot.ScheduleState,
|
||||
userID,
|
||||
normalizedConversationID,
|
||||
taskClassIDs,
|
||||
"",
|
||||
)
|
||||
if preview == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. 合并旧预览里需要保留的字段,避免前端依赖字段突然丢失。
|
||||
existingPreview, err := s.cacheDAO.GetSchedulePlanPreviewFromCache(ctx, userID, normalizedConversationID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取排程预览缓存失败: %w", err)
|
||||
}
|
||||
if existingPreview != nil {
|
||||
preview.TraceID = strings.TrimSpace(existingPreview.TraceID)
|
||||
if len(existingPreview.CandidatePlans) > 0 {
|
||||
preview.CandidatePlans = agentshared.CloneWeekSchedules(existingPreview.CandidatePlans)
|
||||
}
|
||||
if len(existingPreview.AllocatedItems) > 0 {
|
||||
preview.AllocatedItems = agentshared.CloneTaskClassItems(existingPreview.AllocatedItems)
|
||||
}
|
||||
if len(preview.TaskClassIDs) == 0 && len(existingPreview.TaskClassIDs) > 0 {
|
||||
preview.TaskClassIDs = append([]int(nil), existingPreview.TaskClassIDs...)
|
||||
}
|
||||
}
|
||||
if preview.CandidatePlans == nil {
|
||||
preview.CandidatePlans = make([]model.UserWeekSchedule, 0)
|
||||
}
|
||||
if preview.HybridEntries == nil {
|
||||
preview.HybridEntries = make([]model.HybridScheduleEntry, 0)
|
||||
}
|
||||
if preview.TaskClassIDs == nil {
|
||||
preview.TaskClassIDs = make([]int, 0)
|
||||
}
|
||||
|
||||
// 5. 回写 Redis 预览缓存;失败则返回错误,让前端可感知并重试。
|
||||
if err := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedConversationID, preview); err != nil {
|
||||
return fmt.Errorf("刷新排程预览缓存失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
148
backend/services/agent/sv/agent_stream_fallback.go
Normal file
148
backend/services/agent/sv/agent_stream_fallback.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentprompt "github.com/LoveLosita/smartflow/backend/services/agent/prompt"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// streamChatFallback 是 graph 执行失败时的降级流式聊天。
|
||||
// 内联了旧 agentchat.StreamChat 的核心逻辑,不再依赖 agent/ 包。
|
||||
func (s *AgentService) streamChatFallback(
|
||||
ctx context.Context,
|
||||
llm *llmservice.Client,
|
||||
modelName string,
|
||||
userInput string,
|
||||
ifThinking bool,
|
||||
chatHistory []*schema.Message,
|
||||
outChan chan<- string,
|
||||
reasoningStartAt *time.Time,
|
||||
userID int,
|
||||
chatID string,
|
||||
) (string, string, int, *schema.TokenUsage, error) {
|
||||
messages := make([]*schema.Message, 0, len(chatHistory)+2)
|
||||
messages = append(messages, schema.SystemMessage(agentprompt.SystemPrompt))
|
||||
if len(chatHistory) > 0 {
|
||||
messages = append(messages, chatHistory...)
|
||||
}
|
||||
messages = append(messages, schema.UserMessage(userInput))
|
||||
|
||||
if strings.TrimSpace(modelName) == "" {
|
||||
modelName = "smartflow-worker"
|
||||
}
|
||||
requestID := "chatcmpl-" + uuid.NewString()
|
||||
created := time.Now().Unix()
|
||||
firstChunk := true
|
||||
chunkEmitter := agentstream.NewChunkEmitter(agentstream.NewSSEPayloadEmitter(outChan), requestID, modelName, created)
|
||||
reasoningSummaryClient := s.llmService.LiteClient()
|
||||
if reasoningSummaryClient == nil {
|
||||
reasoningSummaryClient = s.llmService.ProClient()
|
||||
}
|
||||
chunkEmitter.SetReasoningSummaryFunc(s.makeReasoningSummaryFunc(reasoningSummaryClient))
|
||||
chunkEmitter.SetExtraEventHook(func(extra *agentstream.OpenAIChunkExtra) {
|
||||
s.persistAgentTimelineExtraEvent(context.Background(), userID, chatID, extra)
|
||||
})
|
||||
reasoningDigestor, digestorErr := chunkEmitter.NewReasoningDigestor(ctx, "fallback.speak", "fallback")
|
||||
if digestorErr != nil {
|
||||
return "", "", 0, nil, digestorErr
|
||||
}
|
||||
digestorClosed := false
|
||||
closeDigestor := func() {
|
||||
if reasoningDigestor == nil || digestorClosed {
|
||||
return
|
||||
}
|
||||
digestorClosed = true
|
||||
_ = reasoningDigestor.Close(ctx)
|
||||
}
|
||||
defer closeDigestor()
|
||||
|
||||
var localReasoningStartAt *time.Time
|
||||
if reasoningStartAt != nil && !reasoningStartAt.IsZero() {
|
||||
startCopy := reasoningStartAt.In(time.Local)
|
||||
localReasoningStartAt = &startCopy
|
||||
}
|
||||
var reasoningEndAt *time.Time
|
||||
|
||||
thinkingMode := llmservice.ThinkingModeDisabled
|
||||
if ifThinking {
|
||||
thinkingMode = llmservice.ThinkingModeEnabled
|
||||
}
|
||||
|
||||
reader, err := llm.Stream(ctx, messages, llmservice.GenerateOptions{
|
||||
Thinking: thinkingMode,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", 0, nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
var fullText strings.Builder
|
||||
var tokenUsage *schema.TokenUsage
|
||||
for {
|
||||
chunk, recvErr := reader.Recv()
|
||||
if recvErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if recvErr != nil {
|
||||
return "", "", 0, nil, recvErr
|
||||
}
|
||||
|
||||
if chunk != nil && chunk.ResponseMeta != nil && chunk.ResponseMeta.Usage != nil {
|
||||
tokenUsage = agentstream.MergeUsage(tokenUsage, chunk.ResponseMeta.Usage)
|
||||
}
|
||||
|
||||
if chunk != nil {
|
||||
if strings.TrimSpace(chunk.ReasoningContent) != "" && localReasoningStartAt == nil {
|
||||
now := time.Now()
|
||||
localReasoningStartAt = &now
|
||||
}
|
||||
if strings.TrimSpace(chunk.Content) != "" && localReasoningStartAt != nil && reasoningEndAt == nil {
|
||||
now := time.Now()
|
||||
reasoningEndAt = &now
|
||||
}
|
||||
// 1. fallback 链路同样不能透传 raw reasoning_content;
|
||||
// 2. 只把 reasoning 喂给摘要器,正文出现时立即关门丢弃后续摘要。
|
||||
if strings.TrimSpace(chunk.ReasoningContent) != "" && reasoningDigestor != nil {
|
||||
reasoningDigestor.Append(chunk.ReasoningContent)
|
||||
}
|
||||
if chunk.Content != "" {
|
||||
if reasoningDigestor != nil {
|
||||
reasoningDigestor.MarkContentStarted()
|
||||
}
|
||||
if emitErr := chunkEmitter.EmitAssistantText("fallback.speak", "fallback", chunk.Content, firstChunk); emitErr != nil {
|
||||
return "", "", 0, nil, emitErr
|
||||
}
|
||||
fullText.WriteString(chunk.Content)
|
||||
firstChunk = false
|
||||
}
|
||||
}
|
||||
}
|
||||
closeDigestor()
|
||||
|
||||
if finishErr := chunkEmitter.EmitFinish("fallback.speak", "fallback"); finishErr != nil {
|
||||
return "", "", 0, nil, finishErr
|
||||
}
|
||||
if doneErr := chunkEmitter.EmitDone(); doneErr != nil {
|
||||
return "", "", 0, nil, doneErr
|
||||
}
|
||||
|
||||
reasoningDurationSeconds := 0
|
||||
if localReasoningStartAt != nil {
|
||||
if reasoningEndAt == nil {
|
||||
now := time.Now()
|
||||
reasoningEndAt = &now
|
||||
}
|
||||
if reasoningEndAt.After(*localReasoningStartAt) {
|
||||
reasoningDurationSeconds = int(reasoningEndAt.Sub(*localReasoningStartAt) / time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
return fullText.String(), "", reasoningDurationSeconds, tokenUsage, nil
|
||||
}
|
||||
174
backend/services/agent/sv/agent_task_query.go
Normal file
174
backend/services/agent/sv/agent_task_query.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
)
|
||||
|
||||
func (s *AgentService) QueryTasksForTool(ctx context.Context, req agentmodel.TaskQueryRequest) ([]agentmodel.TaskQueryTaskRecord, error) {
|
||||
if req.UserID <= 0 {
|
||||
return nil, errors.New("invalid user_id in task query")
|
||||
}
|
||||
|
||||
var tasks []model.Task
|
||||
var err error
|
||||
|
||||
// 优先使用统一提升链路(含缓存读取 + 读时派生 + outbox 异步落库)。
|
||||
if s.GetTasksWithUrgencyPromotionFunc != nil {
|
||||
tasks, err = s.GetTasksWithUrgencyPromotionFunc(ctx, req.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, respond.UserTasksEmpty) {
|
||||
return make([]agentmodel.TaskQueryTaskRecord, 0), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// 回退:未注入时走旧的 taskRepo 直接读取(无缓存、无持久化)。
|
||||
if s.taskRepo == nil {
|
||||
return nil, errors.New("task repository is nil")
|
||||
}
|
||||
tasks, err = s.taskRepo.GetTasksByUserID(req.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, respond.UserTasksEmpty) {
|
||||
return make([]agentmodel.TaskQueryTaskRecord, 0), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
for i := range tasks {
|
||||
applyReadTimeUrgencyPromotion(&tasks[i], now)
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤、排序、截断。
|
||||
filtered := make([]model.Task, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
if !taskMatchesQueryFilter(task, req) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, task)
|
||||
}
|
||||
|
||||
sortTasksForQuery(filtered, req)
|
||||
if req.Limit > 0 && len(filtered) > req.Limit {
|
||||
filtered = filtered[:req.Limit]
|
||||
}
|
||||
|
||||
records := make([]agentmodel.TaskQueryTaskRecord, 0, len(filtered))
|
||||
for _, task := range filtered {
|
||||
records = append(records, agentmodel.TaskQueryTaskRecord{
|
||||
ID: task.ID,
|
||||
Title: task.Title,
|
||||
PriorityGroup: task.Priority,
|
||||
EstimatedSections: model.NormalizeEstimatedSections(&task.EstimatedSections),
|
||||
IsCompleted: task.IsCompleted,
|
||||
DeadlineAt: task.DeadlineAt,
|
||||
UrgencyThresholdAt: task.UrgencyThresholdAt,
|
||||
})
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func applyReadTimeUrgencyPromotion(task *model.Task, now time.Time) {
|
||||
if task == nil || task.IsCompleted || task.UrgencyThresholdAt == nil {
|
||||
return
|
||||
}
|
||||
if task.UrgencyThresholdAt.After(now) {
|
||||
return
|
||||
}
|
||||
switch task.Priority {
|
||||
case 2:
|
||||
task.Priority = 1
|
||||
case 4:
|
||||
task.Priority = 3
|
||||
}
|
||||
}
|
||||
|
||||
func taskMatchesQueryFilter(task model.Task, req agentmodel.TaskQueryRequest) bool {
|
||||
if !req.IncludeCompleted && task.IsCompleted {
|
||||
return false
|
||||
}
|
||||
if req.Quadrant != nil && task.Priority != *req.Quadrant {
|
||||
return false
|
||||
}
|
||||
keyword := strings.TrimSpace(req.Keyword)
|
||||
if keyword != "" && !strings.Contains(strings.ToLower(task.Title), strings.ToLower(keyword)) {
|
||||
return false
|
||||
}
|
||||
if req.DeadlineAfter != nil {
|
||||
if task.DeadlineAt == nil || task.DeadlineAt.Before(*req.DeadlineAfter) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if req.DeadlineBefore != nil {
|
||||
if task.DeadlineAt == nil || task.DeadlineAt.After(*req.DeadlineBefore) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func sortTasksForQuery(tasks []model.Task, req agentmodel.TaskQueryRequest) {
|
||||
if len(tasks) <= 1 {
|
||||
return
|
||||
}
|
||||
order := strings.ToLower(strings.TrimSpace(req.Order))
|
||||
if order != "desc" {
|
||||
order = "asc"
|
||||
}
|
||||
sortBy := strings.ToLower(strings.TrimSpace(req.SortBy))
|
||||
if sortBy == "" {
|
||||
sortBy = "deadline"
|
||||
}
|
||||
|
||||
sort.SliceStable(tasks, func(i, j int) bool {
|
||||
left := tasks[i]
|
||||
right := tasks[j]
|
||||
switch sortBy {
|
||||
case "priority":
|
||||
if left.Priority != right.Priority {
|
||||
if order == "desc" {
|
||||
return left.Priority > right.Priority
|
||||
}
|
||||
return left.Priority < right.Priority
|
||||
}
|
||||
return left.ID > right.ID
|
||||
case "id":
|
||||
if order == "desc" {
|
||||
return left.ID > right.ID
|
||||
}
|
||||
return left.ID < right.ID
|
||||
default:
|
||||
if less, decided := compareDeadline(left.DeadlineAt, right.DeadlineAt, order); decided {
|
||||
return less
|
||||
}
|
||||
return left.ID > right.ID
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func compareDeadline(left, right *time.Time, order string) (less bool, decided bool) {
|
||||
if left == nil && right == nil {
|
||||
return false, false
|
||||
}
|
||||
if left == nil && right != nil {
|
||||
return false, true
|
||||
}
|
||||
if left != nil && right == nil {
|
||||
return true, true
|
||||
}
|
||||
if left.Equal(*right) {
|
||||
return false, false
|
||||
}
|
||||
if order == "desc" {
|
||||
return left.After(*right), true
|
||||
}
|
||||
return left.Before(*right), true
|
||||
}
|
||||
663
backend/services/agent/sv/agent_timeline.go
Normal file
663
backend/services/agent/sv/agent_timeline.go
Normal file
@@ -0,0 +1,663 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
eventsvc "github.com/LoveLosita/smartflow/backend/service/events"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetConversationTimeline 返回指定会话的统一时间线(正文+卡片)列表。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只读,不修改会话状态;
|
||||
// 2. 顺序以 seq 为准,保证刷新后可稳定重建;
|
||||
// 3. 优先读 Redis 时间线缓存,未命中再回源 MySQL。
|
||||
func (s *AgentService) GetConversationTimeline(ctx context.Context, userID int, chatID string) ([]model.GetConversationTimelineItem, error) {
|
||||
normalizedChatID := normalizeConversationID(chatID)
|
||||
if userID <= 0 || strings.TrimSpace(normalizedChatID) == "" {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
exists, err := s.repo.IfChatExists(ctx, userID, normalizedChatID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
if s.cacheDAO != nil {
|
||||
cacheItems, cacheErr := s.cacheDAO.GetConversationTimelineFromCache(ctx, userID, normalizedChatID)
|
||||
if cacheErr == nil && cacheItems != nil {
|
||||
return normalizeConversationTimelineItems(cacheItems), nil
|
||||
}
|
||||
if cacheErr != nil {
|
||||
log.Printf("读取会话时间线缓存失败 user=%d chat=%s err=%v", userID, normalizedChatID, cacheErr)
|
||||
}
|
||||
}
|
||||
|
||||
events, err := s.repo.ListConversationTimelineEvents(ctx, userID, normalizedChatID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := buildConversationTimelineItemsFromDB(events)
|
||||
|
||||
if s.cacheDAO != nil {
|
||||
if err := s.cacheDAO.SetConversationTimelineToCache(ctx, userID, normalizedChatID, items); err != nil {
|
||||
log.Printf("回填会话时间线缓存失败 user=%d chat=%s err=%v", userID, normalizedChatID, err)
|
||||
}
|
||||
if len(items) > 0 {
|
||||
if err := s.cacheDAO.SetConversationTimelineSeq(ctx, userID, normalizedChatID, items[len(items)-1].Seq); err != nil {
|
||||
log.Printf("回填会话时间线 seq 失败 user=%d chat=%s err=%v", userID, normalizedChatID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeConversationTimelineItems(items), nil
|
||||
}
|
||||
|
||||
// appendConversationTimelineEvent 统一追加单条时间线事件到 Redis + outbox。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先分配同会话内单调递增的 seq,优先走 Redis,Redis 不可用时回退 DB;
|
||||
// 2. 再把事件同步追加到 Redis timeline cache,保证刷新前的用户体验连续;
|
||||
// 3. 最后发布 outbox 事件异步落 MySQL,与 chat history 的可靠落库方式对齐;
|
||||
// 4. 未注入 eventPublisher 时走同步 MySQL fallback,方便本地极简环境启动。
|
||||
func (s *AgentService) appendConversationTimelineEvent(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
chatID string,
|
||||
kind string,
|
||||
role string,
|
||||
content string,
|
||||
payload map[string]any,
|
||||
tokensConsumed int,
|
||||
) (int64, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return 0, errors.New("agent service is not initialized")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
normalizedChatID := strings.TrimSpace(chatID)
|
||||
normalizedRole := strings.TrimSpace(role)
|
||||
normalizedKind := canonicalizeTimelineKind(kind, normalizedRole)
|
||||
normalizedContent := strings.TrimSpace(content)
|
||||
if userID <= 0 || normalizedChatID == "" || normalizedKind == "" {
|
||||
return 0, errors.New("invalid timeline event identity")
|
||||
}
|
||||
|
||||
normalizedContent, normalizedPayload, shouldPersist := normalizeConversationTimelinePersistMaterial(normalizedKind, normalizedContent, payload)
|
||||
if !shouldPersist {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
seq, err := s.nextConversationTimelineSeq(ctx, userID, normalizedChatID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
persistPayload := (model.ChatTimelinePersistPayload{
|
||||
UserID: userID,
|
||||
ConversationID: normalizedChatID,
|
||||
Seq: seq,
|
||||
Kind: normalizedKind,
|
||||
Role: normalizedRole,
|
||||
Content: normalizedContent,
|
||||
PayloadJSON: marshalTimelinePayloadJSON(normalizedPayload),
|
||||
TokensConsumed: tokensConsumed,
|
||||
}).Normalize()
|
||||
if s.eventPublisher != nil {
|
||||
now := time.Now()
|
||||
|
||||
// 1. 先写 Redis timeline cache,让刷新前的本地态和下一轮上下文都能立即看到这条事件。
|
||||
// 2. 再发布 outbox 事件,与 chat history 保持相同的“入队成功即返回”语义。
|
||||
// 3. 若 outbox 发布失败,这里返回 error 交给上层处理,不在本方法里偷偷回退成同步写库。
|
||||
s.appendConversationTimelineCacheNonBlocking(
|
||||
ctx,
|
||||
userID,
|
||||
normalizedChatID,
|
||||
buildConversationTimelineCacheItem(0, seq, normalizedKind, normalizedRole, normalizedContent, normalizedPayload, tokensConsumed, &now),
|
||||
)
|
||||
if err := eventsvc.PublishAgentTimelinePersistRequested(ctx, s.eventPublisher, persistPayload); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return seq, nil
|
||||
}
|
||||
return s.appendConversationTimelineEventSync(ctx, userID, normalizedChatID, persistPayload, normalizedPayload)
|
||||
}
|
||||
|
||||
// appendConversationTimelineEventSync 在未启用 outbox 时同步写 MySQL。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 本方法只作为 eventPublisher 为空时的降级路径,保证本地环境不依赖总线;
|
||||
// 2. 若 seq 唯一键冲突,读取 DB 最大 seq 后补一个新序号,语义与 outbox 消费者保持一致;
|
||||
// 3. MySQL 写入成功后再追加 Redis cache,让缓存拿到数据库生成的 id/created_at。
|
||||
func (s *AgentService) appendConversationTimelineEventSync(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
chatID string,
|
||||
persistPayload model.ChatTimelinePersistPayload,
|
||||
payload map[string]any,
|
||||
) (int64, error) {
|
||||
eventID, eventCreatedAt, err := s.repo.SaveConversationTimelineEvent(ctx, persistPayload)
|
||||
if err != nil {
|
||||
// 1. 这里的冲突通常来自 Redis seq key 过期或落后于 DB。
|
||||
// 2. 由于当前是同步写库链路,可以直接读取 DB 当前最大 seq 并补一个新序号。
|
||||
// 3. 若重试后仍失败,则把数据库错误原样抛给上层,避免悄悄吞掉真实问题。
|
||||
if !model.IsTimelineSeqConflictError(err) {
|
||||
return 0, err
|
||||
}
|
||||
maxSeq, seqErr := s.repo.GetConversationTimelineMaxSeq(ctx, userID, chatID)
|
||||
if seqErr != nil {
|
||||
return 0, seqErr
|
||||
}
|
||||
persistPayload.Seq = maxSeq + 1
|
||||
eventID, eventCreatedAt, err = s.repo.SaveConversationTimelineEvent(ctx, persistPayload)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if s.cacheDAO != nil {
|
||||
if setErr := s.cacheDAO.SetConversationTimelineSeq(ctx, userID, chatID, persistPayload.Seq); setErr != nil {
|
||||
log.Printf("回填时间线 seq 到 Redis 失败 user=%d chat=%s seq=%d err=%v", userID, chatID, persistPayload.Seq, setErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.appendConversationTimelineCacheNonBlocking(
|
||||
ctx,
|
||||
userID,
|
||||
chatID,
|
||||
buildConversationTimelineCacheItem(
|
||||
eventID,
|
||||
persistPayload.Seq,
|
||||
persistPayload.Kind,
|
||||
persistPayload.Role,
|
||||
persistPayload.Content,
|
||||
payload,
|
||||
persistPayload.TokensConsumed,
|
||||
eventCreatedAt,
|
||||
),
|
||||
)
|
||||
return persistPayload.Seq, nil
|
||||
}
|
||||
|
||||
// appendConversationTimelineCacheNonBlocking 尽力把单条 timeline 事件追加到 Redis。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 缓存失败不能反向影响主链路,因为 MySQL/outbox 才是最终可靠写入;
|
||||
// 2. 这里统一记录错误日志,方便排查 Redis 不可用或 payload 序列化问题;
|
||||
// 3. item 由调用方提前标准化,本方法不再二次裁剪业务字段。
|
||||
func (s *AgentService) appendConversationTimelineCacheNonBlocking(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
chatID string,
|
||||
item model.GetConversationTimelineItem,
|
||||
) {
|
||||
if s.cacheDAO == nil {
|
||||
return
|
||||
}
|
||||
if err := s.cacheDAO.AppendConversationTimelineEventToCache(ctx, userID, chatID, item); err != nil {
|
||||
log.Printf("追加时间线缓存失败 user=%d chat=%s seq=%d kind=%s err=%v", userID, chatID, item.Seq, item.Kind, err)
|
||||
}
|
||||
}
|
||||
|
||||
// nextConversationTimelineSeq 负责分配一条新的 timeline seq。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 优先走 Redis INCR,避免所有事件都串行依赖 MySQL;
|
||||
// 2. 再用 DB MAX(seq) 做一次自检,尽量把“Redis key 过期/落后”在写入前提前修正;
|
||||
// 3. 若 Redis 不可用,则直接回退到 DB MAX(seq)+1,并把结果尽力回填回 Redis。
|
||||
func (s *AgentService) nextConversationTimelineSeq(ctx context.Context, userID int, chatID string) (int64, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return 0, errors.New("agent service is not initialized")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
normalizedChatID := strings.TrimSpace(chatID)
|
||||
if userID <= 0 || normalizedChatID == "" {
|
||||
return 0, errors.New("invalid timeline seq identity")
|
||||
}
|
||||
|
||||
if s.cacheDAO == nil {
|
||||
return s.nextConversationTimelineSeqFromDB(ctx, userID, normalizedChatID)
|
||||
}
|
||||
|
||||
candidateSeq, err := s.cacheDAO.IncrConversationTimelineSeq(ctx, userID, normalizedChatID)
|
||||
if err != nil {
|
||||
log.Printf("分配时间线 seq 时 Redis INCR 失败,回退 DB user=%d chat=%s err=%v", userID, normalizedChatID, err)
|
||||
return s.nextConversationTimelineSeqFromDB(ctx, userID, normalizedChatID)
|
||||
}
|
||||
|
||||
// 1. Redis key 缺失时,INCR 常会从 1 重新开始,容易和已有 DB 记录撞 seq。
|
||||
// 2. 这里额外对照一次 DB 最大 seq,把明显落后的顺序号提前修正,降低 outbox 消费时的补 seq 概率。
|
||||
// 3. 该自检不会看到“尚未消费到 MySQL 的新 outbox 事件”,因此真正的极端并发兜底仍由消费者承担。
|
||||
maxSeq, err := s.repo.GetConversationTimelineMaxSeq(ctx, userID, normalizedChatID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if candidateSeq > maxSeq {
|
||||
return candidateSeq, nil
|
||||
}
|
||||
|
||||
repairedSeq := maxSeq + 1
|
||||
if err = s.cacheDAO.SetConversationTimelineSeq(ctx, userID, normalizedChatID, repairedSeq); err != nil {
|
||||
log.Printf("修正时间线 seq 到 Redis 失败 user=%d chat=%s seq=%d err=%v", userID, normalizedChatID, repairedSeq, err)
|
||||
}
|
||||
return repairedSeq, nil
|
||||
}
|
||||
|
||||
func (s *AgentService) nextConversationTimelineSeqFromDB(ctx context.Context, userID int, chatID string) (int64, error) {
|
||||
maxSeq, err := s.repo.GetConversationTimelineMaxSeq(ctx, userID, chatID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
nextSeq := maxSeq + 1
|
||||
if s.cacheDAO != nil {
|
||||
if setErr := s.cacheDAO.SetConversationTimelineSeq(ctx, userID, chatID, nextSeq); setErr != nil {
|
||||
log.Printf("回填时间线 seq 到 Redis 失败 user=%d chat=%s seq=%d err=%v", userID, chatID, nextSeq, setErr)
|
||||
}
|
||||
}
|
||||
return nextSeq, nil
|
||||
}
|
||||
|
||||
// normalizeConversationTimelinePersistMaterial 负责把 timeline 原始输入收敛成“可缓存 + 可持久化”的口径。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 对普通事件只做浅拷贝,避免调用方后续继续改 map 影响已入队 payload;
|
||||
// 2. 对 thinking_summary 只保留 detail_summary 与必要 metadata,明确剔除 short_summary;
|
||||
// 3. 若 thinking_summary 最终没有 detail_summary,则返回 shouldPersist=false,仅保留实时 SSE 展示,不进入 timeline。
|
||||
func normalizeConversationTimelinePersistMaterial(kind string, content string, payload map[string]any) (string, map[string]any, bool) {
|
||||
normalizedKind := strings.ToLower(strings.TrimSpace(kind))
|
||||
normalizedContent := strings.TrimSpace(content)
|
||||
if normalizedKind != model.AgentTimelineKindThinkingSummary {
|
||||
return normalizedContent, cloneTimelinePayload(payload), true
|
||||
}
|
||||
return sanitizeThinkingSummaryPersistMaterial(normalizedContent, payload)
|
||||
}
|
||||
|
||||
func sanitizeThinkingSummaryPersistMaterial(content string, payload map[string]any) (string, map[string]any, bool) {
|
||||
detailSummary := readTimelinePayloadString(payload, "detail_summary")
|
||||
if detailSummary == "" {
|
||||
detailSummary = strings.TrimSpace(content)
|
||||
}
|
||||
if detailSummary == "" {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
sanitized := make(map[string]any)
|
||||
copyTrimmedTimelinePayloadField(payload, sanitized, "stage")
|
||||
copyTrimmedTimelinePayloadField(payload, sanitized, "block_id")
|
||||
copyTrimmedTimelinePayloadField(payload, sanitized, "display_mode")
|
||||
copyTimelinePayloadFieldIfPresent(payload, sanitized, "summary_seq")
|
||||
copyTimelinePayloadFieldIfPresent(payload, sanitized, "final")
|
||||
copyTimelinePayloadFieldIfPresent(payload, sanitized, "duration_seconds")
|
||||
sanitized["detail_summary"] = detailSummary
|
||||
|
||||
return detailSummary, sanitized, true
|
||||
}
|
||||
|
||||
func copyTrimmedTimelinePayloadField(src map[string]any, dst map[string]any, key string) {
|
||||
if len(src) == 0 || dst == nil {
|
||||
return
|
||||
}
|
||||
value, ok := src[key]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return
|
||||
}
|
||||
dst[key] = trimmed
|
||||
}
|
||||
|
||||
func copyTimelinePayloadFieldIfPresent(src map[string]any, dst map[string]any, key string) {
|
||||
if len(src) == 0 || dst == nil {
|
||||
return
|
||||
}
|
||||
value, ok := src[key]
|
||||
if !ok || value == nil {
|
||||
return
|
||||
}
|
||||
dst[key] = value
|
||||
}
|
||||
|
||||
// persistAgentTimelineExtraEvent 把 SSE extra 里的结构化事件写入时间线。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 只持久化刷新后仍需重建的业务事件;
|
||||
// 2. short_summary 这类临时展示信息会在 appendConversationTimelineEvent 内被过滤掉;
|
||||
// 3. 失败只记日志,不反向打断当前 SSE 输出。
|
||||
func (s *AgentService) persistAgentTimelineExtraEvent(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
chatID string,
|
||||
extra *agentstream.OpenAIChunkExtra,
|
||||
) {
|
||||
kind, ok := mapTimelineKindFromStreamExtra(extra)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
if _, err := s.appendConversationTimelineEvent(
|
||||
ctx,
|
||||
userID,
|
||||
chatID,
|
||||
kind,
|
||||
"",
|
||||
"",
|
||||
buildTimelinePayloadFromStreamExtra(extra),
|
||||
0,
|
||||
); err != nil {
|
||||
log.Printf("写入 agent 时间线事件失败 user=%d chat=%s kind=%s err=%v", userID, chatID, kind, err)
|
||||
}
|
||||
}
|
||||
|
||||
func buildConversationTimelineCacheItem(
|
||||
eventID int64,
|
||||
seq int64,
|
||||
kind string,
|
||||
role string,
|
||||
content string,
|
||||
payload map[string]any,
|
||||
tokensConsumed int,
|
||||
createdAt *time.Time,
|
||||
) model.GetConversationTimelineItem {
|
||||
item := model.GetConversationTimelineItem{
|
||||
ID: eventID,
|
||||
Seq: seq,
|
||||
Kind: kind,
|
||||
Role: role,
|
||||
Content: content,
|
||||
Payload: cloneTimelinePayload(payload),
|
||||
TokensConsumed: tokensConsumed,
|
||||
}
|
||||
if createdAt != nil {
|
||||
item.CreatedAt = createdAt
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func buildConversationTimelineItemsFromDB(events []model.AgentTimelineEvent) []model.GetConversationTimelineItem {
|
||||
if len(events) == 0 {
|
||||
return make([]model.GetConversationTimelineItem, 0)
|
||||
}
|
||||
items := make([]model.GetConversationTimelineItem, 0, len(events))
|
||||
for _, event := range events {
|
||||
item := model.GetConversationTimelineItem{
|
||||
ID: event.ID,
|
||||
Seq: event.Seq,
|
||||
Kind: strings.TrimSpace(event.Kind),
|
||||
TokensConsumed: event.TokensConsumed,
|
||||
CreatedAt: event.CreatedAt,
|
||||
}
|
||||
if event.Role != nil {
|
||||
item.Role = strings.TrimSpace(*event.Role)
|
||||
}
|
||||
if event.Content != nil {
|
||||
item.Content = strings.TrimSpace(*event.Content)
|
||||
}
|
||||
if event.Payload != nil {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(*event.Payload)), &payload); err == nil && len(payload) > 0 {
|
||||
item.Payload = payload
|
||||
}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return normalizeConversationTimelineItems(items)
|
||||
}
|
||||
|
||||
// normalizeConversationTimelineItems 统一收敛 timeline 的 kind/role 口径,避免前端切分失效。
|
||||
func normalizeConversationTimelineItems(items []model.GetConversationTimelineItem) []model.GetConversationTimelineItem {
|
||||
if len(items) == 0 {
|
||||
return make([]model.GetConversationTimelineItem, 0)
|
||||
}
|
||||
normalized := make([]model.GetConversationTimelineItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
role := strings.ToLower(strings.TrimSpace(item.Role))
|
||||
kind := canonicalizeTimelineKind(item.Kind, role)
|
||||
|
||||
// kind 缺失时尝试从 role 反推文本类型,保障“用户分段锚点”可用。
|
||||
if kind == "" {
|
||||
switch role {
|
||||
case "user":
|
||||
kind = model.AgentTimelineKindUserText
|
||||
case "assistant":
|
||||
kind = model.AgentTimelineKindAssistantText
|
||||
}
|
||||
}
|
||||
// role 缺失时按文本类型补齐,减少前端额外兼容判断。
|
||||
if role == "" {
|
||||
switch kind {
|
||||
case model.AgentTimelineKindUserText:
|
||||
role = "user"
|
||||
case model.AgentTimelineKindAssistantText:
|
||||
role = "assistant"
|
||||
}
|
||||
}
|
||||
|
||||
item.Kind = kind
|
||||
item.Role = role
|
||||
normalized = append(normalized, item)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// canonicalizeTimelineKind 统一 kind 别名,收敛到文档定义值。
|
||||
func canonicalizeTimelineKind(kind string, role string) string {
|
||||
normalizedKind := strings.ToLower(strings.TrimSpace(kind))
|
||||
normalizedRole := strings.ToLower(strings.TrimSpace(role))
|
||||
switch normalizedKind {
|
||||
case model.AgentTimelineKindUserText,
|
||||
model.AgentTimelineKindAssistantText,
|
||||
model.AgentTimelineKindToolCall,
|
||||
model.AgentTimelineKindToolResult,
|
||||
model.AgentTimelineKindConfirmRequest,
|
||||
model.AgentTimelineKindBusinessCard,
|
||||
model.AgentTimelineKindScheduleCompleted,
|
||||
model.AgentTimelineKindThinkingSummary:
|
||||
return normalizedKind
|
||||
case "text", "message", "query":
|
||||
if normalizedRole == "user" {
|
||||
return model.AgentTimelineKindUserText
|
||||
}
|
||||
if normalizedRole == "assistant" {
|
||||
return model.AgentTimelineKindAssistantText
|
||||
}
|
||||
return normalizedKind
|
||||
default:
|
||||
return normalizedKind
|
||||
}
|
||||
}
|
||||
|
||||
func marshalTimelinePayloadJSON(payload map[string]any) string {
|
||||
if len(payload) == 0 {
|
||||
return ""
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func cloneTimelinePayload(payload map[string]any) map[string]any {
|
||||
if len(payload) == 0 {
|
||||
return nil
|
||||
}
|
||||
cloned := make(map[string]any, len(payload))
|
||||
for key, value := range payload {
|
||||
cloned[key] = value
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func mapTimelineKindFromStreamExtra(extra *agentstream.OpenAIChunkExtra) (string, bool) {
|
||||
if extra == nil {
|
||||
return "", false
|
||||
}
|
||||
if isThinkingSummaryStreamExtra(extra) {
|
||||
return model.AgentTimelineKindThinkingSummary, true
|
||||
}
|
||||
switch extra.Kind {
|
||||
case agentstream.StreamExtraKindToolCall:
|
||||
return model.AgentTimelineKindToolCall, true
|
||||
case agentstream.StreamExtraKindToolResult:
|
||||
return model.AgentTimelineKindToolResult, true
|
||||
case agentstream.StreamExtraKindConfirm:
|
||||
return model.AgentTimelineKindConfirmRequest, true
|
||||
case agentstream.StreamExtraKindBusinessCard:
|
||||
return model.AgentTimelineKindBusinessCard, true
|
||||
case agentstream.StreamExtraKindScheduleCompleted:
|
||||
return model.AgentTimelineKindScheduleCompleted, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func buildTimelinePayloadFromStreamExtra(extra *agentstream.OpenAIChunkExtra) map[string]any {
|
||||
if extra == nil {
|
||||
return nil
|
||||
}
|
||||
if isThinkingSummaryStreamExtra(extra) {
|
||||
return buildThinkingSummaryTimelinePayload(extra)
|
||||
}
|
||||
payload := map[string]any{
|
||||
"stage": strings.TrimSpace(extra.Stage),
|
||||
"block_id": strings.TrimSpace(extra.BlockID),
|
||||
"display_mode": string(extra.DisplayMode),
|
||||
}
|
||||
if extra.Tool != nil {
|
||||
toolPayload := map[string]any{
|
||||
"name": strings.TrimSpace(extra.Tool.Name),
|
||||
"status": strings.TrimSpace(extra.Tool.Status),
|
||||
"summary": strings.TrimSpace(extra.Tool.Summary),
|
||||
"arguments_preview": strings.TrimSpace(extra.Tool.ArgumentsPreview),
|
||||
}
|
||||
if len(extra.Tool.ArgumentView) > 0 {
|
||||
toolPayload["argument_view"] = cloneTimelinePayload(extra.Tool.ArgumentView)
|
||||
}
|
||||
if len(extra.Tool.ResultView) > 0 {
|
||||
toolPayload["result_view"] = cloneTimelinePayload(extra.Tool.ResultView)
|
||||
}
|
||||
payload["tool"] = toolPayload
|
||||
}
|
||||
if extra.Confirm != nil {
|
||||
payload["confirm"] = map[string]any{
|
||||
"interaction_id": strings.TrimSpace(extra.Confirm.InteractionID),
|
||||
"title": strings.TrimSpace(extra.Confirm.Title),
|
||||
"summary": strings.TrimSpace(extra.Confirm.Summary),
|
||||
}
|
||||
}
|
||||
if extra.Interrupt != nil {
|
||||
payload["interrupt"] = map[string]any{
|
||||
"interaction_id": strings.TrimSpace(extra.Interrupt.InteractionID),
|
||||
"type": strings.TrimSpace(extra.Interrupt.Type),
|
||||
"summary": strings.TrimSpace(extra.Interrupt.Summary),
|
||||
}
|
||||
}
|
||||
if extra.BusinessCard != nil {
|
||||
payload["business_card"] = cloneStreamBusinessCard(extra.BusinessCard)
|
||||
}
|
||||
if len(extra.Meta) > 0 {
|
||||
payload["meta"] = cloneTimelinePayload(extra.Meta)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func isThinkingSummaryStreamExtra(extra *agentstream.OpenAIChunkExtra) bool {
|
||||
if extra == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(string(extra.Kind)), model.AgentTimelineKindThinkingSummary)
|
||||
}
|
||||
|
||||
func buildThinkingSummaryTimelinePayload(extra *agentstream.OpenAIChunkExtra) map[string]any {
|
||||
payload := map[string]any{
|
||||
"stage": strings.TrimSpace(extra.Stage),
|
||||
"block_id": strings.TrimSpace(extra.BlockID),
|
||||
"display_mode": string(extra.DisplayMode),
|
||||
}
|
||||
|
||||
if extra.ThinkingSummary != nil {
|
||||
summary := extra.ThinkingSummary
|
||||
payload["summary_seq"] = summary.SummarySeq
|
||||
payload["final"] = summary.Final
|
||||
payload["duration_seconds"] = summary.DurationSeconds
|
||||
if detailSummary := strings.TrimSpace(summary.DetailSummary); detailSummary != "" {
|
||||
payload["detail_summary"] = detailSummary
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
if detailSummary := readTimelineExtraMetaString(extra.Meta, "detail_summary"); detailSummary != "" {
|
||||
payload["detail_summary"] = detailSummary
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func readTimelineExtraMetaString(meta map[string]any, key string) string {
|
||||
if len(meta) == 0 {
|
||||
return ""
|
||||
}
|
||||
raw, ok := meta[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
text, ok := raw.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func readTimelinePayloadString(payload map[string]any, key string) string {
|
||||
if len(payload) == 0 {
|
||||
return ""
|
||||
}
|
||||
raw, ok := payload[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
text, ok := raw.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func cloneStreamBusinessCard(card *agentstream.StreamBusinessCardExtra) map[string]any {
|
||||
if card == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := map[string]any{
|
||||
"card_type": strings.TrimSpace(card.CardType),
|
||||
"title": strings.TrimSpace(card.Title),
|
||||
"summary": strings.TrimSpace(card.Summary),
|
||||
"source": strings.TrimSpace(card.Source),
|
||||
}
|
||||
if len(card.Data) > 0 {
|
||||
cloned["data"] = cloneTimelinePayload(card.Data)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
121
backend/services/agent/sv/memory_rpc_reader.go
Normal file
121
backend/services/agent/sv/memory_rpc_reader.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
|
||||
memoryobserve "github.com/LoveLosita/smartflow/backend/memory/observe"
|
||||
memorycontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/memory"
|
||||
)
|
||||
|
||||
// MemoryRPCReaderClient 描述 agent 读取 memory zrpc 所需的最小能力。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只读取候选记忆,不暴露管理写接口;
|
||||
// 2. 不要求调用方知道 gateway/client/memory 的具体实现;
|
||||
// 3. 错误原样返回给预取链路,由 agent 侧负责软降级和观测记录。
|
||||
type MemoryRPCReaderClient interface {
|
||||
Retrieve(ctx context.Context, req memorycontracts.RetrieveRequest) ([]memorycontracts.ItemDTO, error)
|
||||
}
|
||||
|
||||
type memoryRPCReader struct {
|
||||
client MemoryRPCReaderClient
|
||||
observer memoryobserve.Observer
|
||||
metrics memoryobserve.MetricsRecorder
|
||||
}
|
||||
|
||||
// NewMemoryRPCReader 创建跨进程 memory reader 适配器。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只把 agent 内部的 memorymodel.RetrieveRequest 转成共享契约;
|
||||
// 2. 不持有 memory.Module,避免 CP3 后 agent 主链路继续直连本进程记忆服务;
|
||||
// 3. observer / metrics 只用于 agent 注入观测,不参与 retrieve 业务调用;
|
||||
// 4. client 为空时返回 nil,让 SetMemoryReader 保持既有“无 reader 则不注入”的降级语义。
|
||||
func NewMemoryRPCReader(
|
||||
client MemoryRPCReaderClient,
|
||||
observer memoryobserve.Observer,
|
||||
metrics memoryobserve.MetricsRecorder,
|
||||
) MemoryReader {
|
||||
if client == nil {
|
||||
return nil
|
||||
}
|
||||
if observer == nil {
|
||||
observer = memoryobserve.NewNopObserver()
|
||||
}
|
||||
if metrics == nil {
|
||||
metrics = memoryobserve.NewNopMetrics()
|
||||
}
|
||||
return &memoryRPCReader{
|
||||
client: client,
|
||||
observer: observer,
|
||||
metrics: metrics,
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve 通过 memory zrpc 读取候选记忆并转换回 agent 内部 DTO。
|
||||
func (r *memoryRPCReader) Retrieve(ctx context.Context, req memorymodel.RetrieveRequest) ([]memorymodel.ItemDTO, error) {
|
||||
if r == nil || r.client == nil {
|
||||
return nil, errors.New("memory rpc reader client is nil")
|
||||
}
|
||||
items, err := r.client.Retrieve(ctx, memorycontracts.RetrieveRequest{
|
||||
Query: req.Query,
|
||||
UserID: req.UserID,
|
||||
ConversationID: req.ConversationID,
|
||||
AssistantID: req.AssistantID,
|
||||
RunID: req.RunID,
|
||||
MemoryTypes: append([]string(nil), req.MemoryTypes...),
|
||||
Limit: req.Limit,
|
||||
Now: req.Now,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toMemoryModelItems(items), nil
|
||||
}
|
||||
|
||||
// MemoryObserver 暴露 agent 注入链路使用的 observer,保持 CP3 切流前后的注入观测连续。
|
||||
func (r *memoryRPCReader) MemoryObserver() memoryobserve.Observer {
|
||||
if r == nil || r.observer == nil {
|
||||
return memoryobserve.NewNopObserver()
|
||||
}
|
||||
return r.observer
|
||||
}
|
||||
|
||||
// MemoryMetrics 暴露 agent 注入链路使用的 metrics,避免 RPC reader 切流后指标静默丢失。
|
||||
func (r *memoryRPCReader) MemoryMetrics() memoryobserve.MetricsRecorder {
|
||||
if r == nil || r.metrics == nil {
|
||||
return memoryobserve.NewNopMetrics()
|
||||
}
|
||||
return r.metrics
|
||||
}
|
||||
|
||||
// toMemoryModelItems 只做跨层 DTO 字段搬运,不改变排序、过滤和记忆内容。
|
||||
func toMemoryModelItems(items []memorycontracts.ItemDTO) []memorymodel.ItemDTO {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]memorymodel.ItemDTO, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, memorymodel.ItemDTO{
|
||||
ID: item.ID,
|
||||
UserID: item.UserID,
|
||||
ConversationID: item.ConversationID,
|
||||
AssistantID: item.AssistantID,
|
||||
RunID: item.RunID,
|
||||
MemoryType: item.MemoryType,
|
||||
Title: item.Title,
|
||||
Content: item.Content,
|
||||
ContentHash: item.ContentHash,
|
||||
Confidence: item.Confidence,
|
||||
Importance: item.Importance,
|
||||
SensitivityLevel: item.SensitivityLevel,
|
||||
IsExplicit: item.IsExplicit,
|
||||
Status: item.Status,
|
||||
TTLAt: item.TTLAt,
|
||||
CreatedAt: item.CreatedAt,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
112
backend/services/agent/sv/reasoning_summary.go
Normal file
112
backend/services/agent/sv/reasoning_summary.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
agentprompt "github.com/LoveLosita/smartflow/backend/services/agent/prompt"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
)
|
||||
|
||||
const reasoningSummaryMaxTokens = 700
|
||||
|
||||
type reasoningSummaryLLMResponse struct {
|
||||
ShortSummary string `json:"short_summary"`
|
||||
DetailSummary string `json:"detail_summary"`
|
||||
}
|
||||
|
||||
// makeReasoningSummaryFunc 把便宜模型封装成 stream 层可注入的摘要函数。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. service 层负责选择模型与 prompt,stream 层只负责调度和闸门;
|
||||
// 2. 这里不持久化摘要,持久化统一走 ChunkEmitter 的 extra hook;
|
||||
// 3. 摘要失败时返回 error,由 ReasoningDigestor 吞掉并等待下一次水位线/Flush 兜底。
|
||||
func (s *AgentService) makeReasoningSummaryFunc(client *llmservice.Client) agentstream.ReasoningSummaryFunc {
|
||||
if client == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return func(ctx context.Context, input agentstream.ReasoningSummaryInput) (agentstream.StreamThinkingSummaryExtra, error) {
|
||||
previousSummary := ""
|
||||
if input.PreviousSummary != nil {
|
||||
previousSummary = input.PreviousSummary.DetailSummary
|
||||
if strings.TrimSpace(previousSummary) == "" {
|
||||
previousSummary = input.PreviousSummary.ShortSummary
|
||||
}
|
||||
}
|
||||
|
||||
messages := agentprompt.BuildReasoningSummaryMessages(agentprompt.ReasoningSummaryPromptInput{
|
||||
FullReasoning: input.FullReasoning,
|
||||
DeltaReasoning: input.DeltaReasoning,
|
||||
PreviousSummary: previousSummary,
|
||||
CandidateSeq: input.CandidateSeq,
|
||||
Final: input.Final,
|
||||
DurationSeconds: input.DurationSeconds,
|
||||
})
|
||||
|
||||
resp, rawResult, err := llmservice.GenerateJSON[reasoningSummaryLLMResponse](
|
||||
ctx,
|
||||
client,
|
||||
messages,
|
||||
llmservice.GenerateOptions{
|
||||
Temperature: 0.1,
|
||||
MaxTokens: reasoningSummaryMaxTokens,
|
||||
Thinking: llmservice.ThinkingModeDisabled,
|
||||
Metadata: map[string]any{
|
||||
"stage": "reasoning_summary",
|
||||
"candidate_seq": input.CandidateSeq,
|
||||
"final": input.Final,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] reasoning 摘要模型调用失败 seq=%d final=%v err=%v raw=%s",
|
||||
input.CandidateSeq,
|
||||
input.Final,
|
||||
err,
|
||||
truncateReasoningSummaryRaw(rawResult),
|
||||
)
|
||||
return agentstream.StreamThinkingSummaryExtra{}, err
|
||||
}
|
||||
|
||||
summary := agentstream.StreamThinkingSummaryExtra{
|
||||
ShortSummary: strings.TrimSpace(resp.ShortSummary),
|
||||
DetailSummary: limitReasoningDetailSummary(
|
||||
resp.DetailSummary,
|
||||
agentprompt.ReasoningSummaryDetailRuneLimit(input.FullReasoning, input.DeltaReasoning),
|
||||
),
|
||||
}
|
||||
if summary.ShortSummary == "" && summary.DetailSummary == "" {
|
||||
return agentstream.StreamThinkingSummaryExtra{}, errors.New("reasoning 摘要模型返回空摘要")
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
}
|
||||
|
||||
func limitReasoningDetailSummary(text string, maxRunes int) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" || maxRunes <= 0 {
|
||||
return text
|
||||
}
|
||||
|
||||
runes := []rune(text)
|
||||
if len(runes) <= maxRunes {
|
||||
return text
|
||||
}
|
||||
return string(runes[:maxRunes])
|
||||
}
|
||||
|
||||
func truncateReasoningSummaryRaw(raw *llmservice.TextResult) string {
|
||||
if raw == nil {
|
||||
return ""
|
||||
}
|
||||
text := strings.TrimSpace(raw.Text)
|
||||
runes := []rune(text)
|
||||
if len(runes) <= 200 {
|
||||
return text
|
||||
}
|
||||
return string(runes[:200]) + "..."
|
||||
}
|
||||
330
backend/services/agent/sv/schedule_rpc_provider.go
Normal file
330
backend/services/agent/sv/schedule_rpc_provider.go
Normal file
@@ -0,0 +1,330 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
agentconv "github.com/LoveLosita/smartflow/backend/services/agent/conv"
|
||||
scheduletool "github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
schedulecontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/schedule"
|
||||
taskclasscontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclass"
|
||||
)
|
||||
|
||||
const scheduleProviderRPCTimeout = 6 * time.Second
|
||||
|
||||
// ScheduleAgentRPCClient 描述 agent schedule provider 读取 schedule 服务所需的最小能力。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只读取按周原始日程槽位事实;
|
||||
// 2. 不暴露 schedule DAO、缓存或写入状态机;
|
||||
// 3. 返回 JSON 契约后由 provider 复用既有 LoadScheduleState 建模逻辑。
|
||||
type ScheduleAgentRPCClient interface {
|
||||
GetAgentWeekSchedule(ctx context.Context, req schedulecontracts.AgentScheduleWeekRequest) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
// TaskClassAgentReadRPCClient 描述 agent schedule provider 读取 task-class 服务所需的最小能力。
|
||||
type TaskClassAgentReadRPCClient interface {
|
||||
GetAgentTaskClasses(ctx context.Context, req taskclasscontracts.AgentTaskClassesRequest) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
// TaskClassAgentRPCClient 聚合 agent 当前依赖的 task-class RPC 写入与读取能力。
|
||||
type TaskClassAgentRPCClient interface {
|
||||
TaskClassUpsertRPCClient
|
||||
TaskClassAgentReadRPCClient
|
||||
}
|
||||
|
||||
// ScheduleRPCProvider 通过 schedule/task-class zrpc 构建 agent ScheduleState。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只替换 agent schedule provider 的 DAO 读取路径;
|
||||
// 2. 窗口推导、extra category 与 ScheduleState 建模继续复用 agent/conv 老逻辑;
|
||||
// 3. 不负责持久化 Diff,不改变 confirm/apply 链路。
|
||||
type ScheduleRPCProvider struct {
|
||||
scheduleClient ScheduleAgentRPCClient
|
||||
taskClassClient TaskClassAgentReadRPCClient
|
||||
}
|
||||
|
||||
func NewScheduleRPCProvider(scheduleClient ScheduleAgentRPCClient, taskClassClient TaskClassAgentReadRPCClient) *ScheduleRPCProvider {
|
||||
return &ScheduleRPCProvider{
|
||||
scheduleClient: scheduleClient,
|
||||
taskClassClient: taskClassClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ScheduleRPCProvider) LoadScheduleState(ctx context.Context, userID int) (*scheduletool.ScheduleState, error) {
|
||||
taskClasses, err := p.loadCompleteTaskClasses(ctx, userID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.loadScheduleStateWithTaskClasses(ctx, userID, taskClasses, true)
|
||||
}
|
||||
|
||||
func (p *ScheduleRPCProvider) LoadScheduleStateForTaskClasses(ctx context.Context, userID int, taskClassIDs []int) (*scheduletool.ScheduleState, error) {
|
||||
if len(taskClassIDs) == 0 {
|
||||
return p.LoadScheduleState(ctx, userID)
|
||||
}
|
||||
taskClasses, err := p.loadCompleteTaskClasses(ctx, userID, taskClassIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.loadScheduleStateWithTaskClasses(ctx, userID, taskClasses, false)
|
||||
}
|
||||
|
||||
func (p *ScheduleRPCProvider) LoadTaskClassMetas(ctx context.Context, userID int, taskClassIDs []int) ([]scheduletool.TaskClassMeta, error) {
|
||||
if len(taskClassIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
taskClasses, err := p.loadCompleteTaskClasses(ctx, userID, taskClassIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return agentconv.TaskClassesToScheduleMetas(taskClasses), nil
|
||||
}
|
||||
|
||||
func (p *ScheduleRPCProvider) loadScheduleStateWithTaskClasses(ctx context.Context, userID int, taskClasses []model.TaskClass, allowCurrentWeekFallback bool) (*scheduletool.ScheduleState, error) {
|
||||
windowDays, weeks := agentconv.BuildWindowFromTaskClasses(taskClasses)
|
||||
if len(windowDays) == 0 {
|
||||
if !allowCurrentWeekFallback {
|
||||
return nil, fmt.Errorf("任务类缺少有效时间窗:请补充 start_date/end_date 后再进行智能编排")
|
||||
}
|
||||
var err error
|
||||
windowDays, weeks, err = agentconv.BuildCurrentWeekWindow()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
allSchedules := make([]model.Schedule, 0)
|
||||
for _, week := range weeks {
|
||||
weekSchedules, err := p.loadWeekSchedules(ctx, userID, week)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("通过 schedule RPC 加载用户周日程失败 week=%d: %w", week, err)
|
||||
}
|
||||
allSchedules = append(allSchedules, weekSchedules...)
|
||||
}
|
||||
|
||||
extraItemCategories := agentconv.BuildExtraItemCategories(allSchedules, taskClasses)
|
||||
return agentconv.LoadScheduleState(allSchedules, taskClasses, extraItemCategories, windowDays), nil
|
||||
}
|
||||
|
||||
func (p *ScheduleRPCProvider) loadCompleteTaskClasses(ctx context.Context, userID int, taskClassIDs []int) ([]model.TaskClass, error) {
|
||||
if p == nil || p.taskClassClient == nil {
|
||||
return nil, errors.New("task-class rpc reader is nil")
|
||||
}
|
||||
callCtx, cancel := context.WithTimeout(ctx, scheduleProviderRPCTimeout)
|
||||
defer cancel()
|
||||
|
||||
raw, err := p.taskClassClient.GetAgentTaskClasses(callCtx, taskclasscontracts.AgentTaskClassesRequest{
|
||||
UserID: userID,
|
||||
TaskClassIDs: append([]int(nil), taskClassIDs...),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp taskclasscontracts.AgentTaskClassesResponse
|
||||
if len(raw) > 0 && string(raw) != "null" {
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
taskClasses := make([]model.TaskClass, 0, len(resp.TaskClasses))
|
||||
for _, item := range resp.TaskClasses {
|
||||
taskClass, err := agentTaskClassToModel(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
taskClasses = append(taskClasses, taskClass)
|
||||
}
|
||||
return taskClasses, nil
|
||||
}
|
||||
|
||||
func (p *ScheduleRPCProvider) loadWeekSchedules(ctx context.Context, userID int, week int) ([]model.Schedule, error) {
|
||||
if p == nil || p.scheduleClient == nil {
|
||||
return nil, errors.New("schedule rpc reader is nil")
|
||||
}
|
||||
callCtx, cancel := context.WithTimeout(ctx, scheduleProviderRPCTimeout)
|
||||
defer cancel()
|
||||
|
||||
raw, err := p.scheduleClient.GetAgentWeekSchedule(callCtx, schedulecontracts.AgentScheduleWeekRequest{
|
||||
UserID: userID,
|
||||
Week: week,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp schedulecontracts.AgentScheduleWeekResponse
|
||||
if len(raw) > 0 && string(raw) != "null" {
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
schedules := make([]model.Schedule, 0, len(resp.Schedules))
|
||||
for _, item := range resp.Schedules {
|
||||
schedules = append(schedules, agentScheduleSlotToModel(item))
|
||||
}
|
||||
return schedules, nil
|
||||
}
|
||||
|
||||
func agentTaskClassToModel(in taskclasscontracts.AgentTaskClass) (model.TaskClass, error) {
|
||||
startDate, err := parseAgentDate(in.StartDate)
|
||||
if err != nil {
|
||||
return model.TaskClass{}, err
|
||||
}
|
||||
endDate, err := parseAgentDate(in.EndDate)
|
||||
if err != nil {
|
||||
return model.TaskClass{}, err
|
||||
}
|
||||
items := make([]model.TaskClassItem, 0, len(in.Items))
|
||||
for _, item := range in.Items {
|
||||
content := item.Content
|
||||
items = append(items, model.TaskClassItem{
|
||||
ID: item.ID,
|
||||
CategoryID: cloneIntPtr(item.CategoryID),
|
||||
Order: cloneIntPtr(item.Order),
|
||||
Content: &content,
|
||||
EmbeddedTime: taskClassContractTargetTimeToModel(item.EmbeddedTime),
|
||||
Status: cloneIntPtr(item.Status),
|
||||
})
|
||||
}
|
||||
return model.TaskClass{
|
||||
ID: in.ID,
|
||||
UserID: intPtrOrNil(in.UserID),
|
||||
Name: stringPtrOrNil(in.Name),
|
||||
Mode: stringPtrOrNil(in.Mode),
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
SubjectType: stringPtrOrNil(in.SubjectType),
|
||||
DifficultyLevel: stringPtrOrNil(in.DifficultyLevel),
|
||||
CognitiveIntensity: stringPtrOrNil(in.CognitiveIntensity),
|
||||
TotalSlots: intPtrOrNil(in.TotalSlots),
|
||||
AllowFillerCourse: boolPtr(in.AllowFillerCourse),
|
||||
Strategy: stringPtrOrNil(in.Strategy),
|
||||
ExcludedSlots: model.IntSlice(append([]int(nil), in.ExcludedSlots...)),
|
||||
ExcludedDaysOfWeek: model.IntSlice(append([]int(nil), in.ExcludedDaysOfWeek...)),
|
||||
Items: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func agentScheduleSlotToModel(in schedulecontracts.AgentScheduleSlot) model.Schedule {
|
||||
return model.Schedule{
|
||||
ID: in.ID,
|
||||
EventID: in.EventID,
|
||||
UserID: in.UserID,
|
||||
Week: in.Week,
|
||||
DayOfWeek: in.DayOfWeek,
|
||||
Section: in.Section,
|
||||
EmbeddedTaskID: cloneIntPtr(in.EmbeddedTaskID),
|
||||
Status: in.Status,
|
||||
Event: agentScheduleEventToModel(in.Event),
|
||||
EmbeddedTask: agentScheduleTaskItemToModel(in.EmbeddedTask),
|
||||
}
|
||||
}
|
||||
|
||||
func agentScheduleEventToModel(in *schedulecontracts.AgentScheduleEvent) *model.ScheduleEvent {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
return &model.ScheduleEvent{
|
||||
ID: in.ID,
|
||||
UserID: in.UserID,
|
||||
Name: in.Name,
|
||||
Location: cloneStringPtr(in.Location),
|
||||
Type: in.Type,
|
||||
RelID: cloneIntPtr(in.RelID),
|
||||
TaskSourceType: in.TaskSourceType,
|
||||
CanBeEmbedded: in.CanBeEmbedded,
|
||||
StartTime: in.StartTime,
|
||||
EndTime: in.EndTime,
|
||||
}
|
||||
}
|
||||
|
||||
func agentScheduleTaskItemToModel(in *schedulecontracts.AgentScheduleTaskItem) *model.TaskClassItem {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
content := in.Content
|
||||
return &model.TaskClassItem{
|
||||
ID: in.ID,
|
||||
CategoryID: cloneIntPtr(in.CategoryID),
|
||||
Order: cloneIntPtr(in.Order),
|
||||
Content: &content,
|
||||
EmbeddedTime: scheduleContractTargetTimeToModel(in.EmbeddedTime),
|
||||
Status: cloneIntPtr(in.Status),
|
||||
}
|
||||
}
|
||||
|
||||
func parseAgentDate(value string) (*time.Time, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parsed, err := time.ParseInLocation("2006-01-02", trimmed, time.Local)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
func taskClassContractTargetTimeToModel(value *taskclasscontracts.TargetTime) *model.TargetTime {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return &model.TargetTime{
|
||||
Week: value.Week,
|
||||
DayOfWeek: value.DayOfWeek,
|
||||
SectionFrom: value.SectionFrom,
|
||||
SectionTo: value.SectionTo,
|
||||
}
|
||||
}
|
||||
|
||||
func scheduleContractTargetTimeToModel(value *schedulecontracts.AgentScheduleTargetTime) *model.TargetTime {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return &model.TargetTime{
|
||||
Week: value.Week,
|
||||
DayOfWeek: value.DayOfWeek,
|
||||
SectionFrom: value.SectionFrom,
|
||||
SectionTo: value.SectionTo,
|
||||
}
|
||||
}
|
||||
|
||||
func stringPtrOrNil(value string) *string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return &trimmed
|
||||
}
|
||||
|
||||
func intPtrOrNil(value int) *int {
|
||||
if value == 0 {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func boolPtr(value bool) *bool {
|
||||
return &value
|
||||
}
|
||||
|
||||
func cloneIntPtr(value *int) *int {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copied := *value
|
||||
return &copied
|
||||
}
|
||||
|
||||
func cloneStringPtr(value *string) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copied := *value
|
||||
return &copied
|
||||
}
|
||||
121
backend/services/agent/sv/task_class_rpc_adapter.go
Normal file
121
backend/services/agent/sv/task_class_rpc_adapter.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
agenttools "github.com/LoveLosita/smartflow/backend/services/agent/tools"
|
||||
taskclasscontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclass"
|
||||
)
|
||||
|
||||
const taskClassUpsertRPCTimeout = 6 * time.Second
|
||||
|
||||
// TaskClassUpsertRPCClient 描述 agent 写入任务类时依赖的 task-class RPC 最小能力。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只覆盖 upsert_task_class 工具需要的新增/更新能力;
|
||||
// 2. 不暴露 task-class DAO、事务细节或 schedule 迁移期直写语义;
|
||||
// 3. 读取型能力由 schedule provider 的独立接口承载,避免接口膨胀。
|
||||
type TaskClassUpsertRPCClient interface {
|
||||
AddTaskClass(ctx context.Context, req taskclasscontracts.UpsertTaskClassRequest) (json.RawMessage, error)
|
||||
UpdateTaskClass(ctx context.Context, req taskclasscontracts.UpsertTaskClassRequest) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
type taskClassRPCUpsertAdapter struct {
|
||||
client TaskClassUpsertRPCClient
|
||||
}
|
||||
|
||||
// NewTaskClassRPCUpsertFunc 把 task-class zrpc client 适配成 agent 工具写入函数。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只替换 agent upsert_task_class 的 DAO 直连路径;
|
||||
// 2. 入参仍复用 agent 工具层已标准化的 UserAddTaskClassRequest;
|
||||
// 3. client 为空时返回会失败的闭包,让工具层保留既有错误包装语义。
|
||||
func NewTaskClassRPCUpsertFunc(client TaskClassUpsertRPCClient) func(userID int, input agenttools.TaskClassUpsertInput) (agenttools.TaskClassUpsertPersistResult, error) {
|
||||
adapter := &taskClassRPCUpsertAdapter{client: client}
|
||||
return adapter.UpsertTaskClass
|
||||
}
|
||||
|
||||
// UpsertTaskClass 通过 task-class zrpc 新增或更新任务类,并返回稳定 task_class_id。
|
||||
func (a *taskClassRPCUpsertAdapter) UpsertTaskClass(userID int, input agenttools.TaskClassUpsertInput) (agenttools.TaskClassUpsertPersistResult, error) {
|
||||
if a == nil || a.client == nil {
|
||||
return agenttools.TaskClassUpsertPersistResult{}, errors.New("task-class rpc client is nil")
|
||||
}
|
||||
req := taskClassUpsertInputToContract(userID, input)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), taskClassUpsertRPCTimeout)
|
||||
defer cancel()
|
||||
|
||||
var raw json.RawMessage
|
||||
var err error
|
||||
created := input.ID == 0
|
||||
// 调用目的:把 agent 工具产出的任务类写入 task-class 服务,避免 agent 继续直连 task_classes/task_items。
|
||||
if created {
|
||||
raw, err = a.client.AddTaskClass(ctx, req)
|
||||
} else {
|
||||
raw, err = a.client.UpdateTaskClass(ctx, req)
|
||||
}
|
||||
if err != nil {
|
||||
return agenttools.TaskClassUpsertPersistResult{}, err
|
||||
}
|
||||
|
||||
var resp taskclasscontracts.UpsertTaskClassResponse
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return agenttools.TaskClassUpsertPersistResult{}, err
|
||||
}
|
||||
if resp.TaskClassID <= 0 {
|
||||
return agenttools.TaskClassUpsertPersistResult{}, errors.New("task-class rpc upsert returned invalid task_class_id")
|
||||
}
|
||||
return agenttools.TaskClassUpsertPersistResult{
|
||||
TaskClassID: resp.TaskClassID,
|
||||
Created: resp.Created,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func taskClassUpsertInputToContract(userID int, input agenttools.TaskClassUpsertInput) taskclasscontracts.UpsertTaskClassRequest {
|
||||
req := input.Request
|
||||
items := make([]taskclasscontracts.UpsertTaskClassItemConfig, 0, len(req.Items))
|
||||
for _, item := range req.Items {
|
||||
items = append(items, taskclasscontracts.UpsertTaskClassItemConfig{
|
||||
ID: item.ID,
|
||||
Order: item.Order,
|
||||
Content: strings.TrimSpace(item.Content),
|
||||
EmbeddedTime: toTaskClassContractTargetTime(item.EmbeddedTime),
|
||||
})
|
||||
}
|
||||
return taskclasscontracts.UpsertTaskClassRequest{
|
||||
UserID: userID,
|
||||
TaskClassID: input.ID,
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
StartDate: strings.TrimSpace(req.StartDate),
|
||||
EndDate: strings.TrimSpace(req.EndDate),
|
||||
Mode: strings.TrimSpace(req.Mode),
|
||||
SubjectType: strings.TrimSpace(req.SubjectType),
|
||||
DifficultyLevel: strings.TrimSpace(req.DifficultyLevel),
|
||||
CognitiveIntensity: strings.TrimSpace(req.CognitiveIntensity),
|
||||
Config: taskclasscontracts.UpsertTaskClassConfig{
|
||||
TotalSlots: req.Config.TotalSlots,
|
||||
AllowFillerCourse: req.Config.AllowFillerCourse,
|
||||
Strategy: strings.TrimSpace(req.Config.Strategy),
|
||||
ExcludedSlots: append([]int(nil), req.Config.ExcludedSlots...),
|
||||
ExcludedDaysOfWeek: append([]int(nil), req.Config.ExcludedDaysOfWeek...),
|
||||
},
|
||||
Items: items,
|
||||
}
|
||||
}
|
||||
|
||||
func toTaskClassContractTargetTime(value *model.TargetTime) *taskclasscontracts.TargetTime {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return &taskclasscontracts.TargetTime{
|
||||
Week: value.Week,
|
||||
DayOfWeek: value.DayOfWeek,
|
||||
SectionFrom: value.SectionFrom,
|
||||
SectionTo: value.SectionTo,
|
||||
}
|
||||
}
|
||||
194
backend/services/agent/sv/task_rpc_adapter.go
Normal file
194
backend/services/agent/sv/task_rpc_adapter.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
taskcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/task"
|
||||
)
|
||||
|
||||
const quickTaskCreateRPCTimeout = 3 * time.Second
|
||||
|
||||
// TaskRPCClient 描述 agent 快捷任务链路访问 task zrpc 所需的最小能力。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只覆盖快捷任务的创建和查询,不暴露 task DAO 或其它写接口;
|
||||
// 2. 不要求 agent 编排层感知 pb / grpc 细节;
|
||||
// 3. 错误原样返回,由 quick_task 节点转换成面向用户的失败文案。
|
||||
type TaskRPCClient interface {
|
||||
AddTask(ctx context.Context, req taskcontracts.AddTaskRequest) (json.RawMessage, error)
|
||||
GetUserTasks(ctx context.Context, userID int) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
type taskRPCAdapter struct {
|
||||
client TaskRPCClient
|
||||
}
|
||||
|
||||
// NewTaskRPCQuickTaskDeps 把 task zrpc client 适配成 agent 快捷任务依赖。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只替换 agent quick task 的 task DAO 直连路径;
|
||||
// 2. 不迁移 task-class upsert、schedule provider 或 agent 编排本体;
|
||||
// 3. client 为空时返回零值依赖,让 quick_task 节点沿用既有“依赖缺失则报错”语义。
|
||||
func NewTaskRPCQuickTaskDeps(client TaskRPCClient) agentmodel.QuickTaskDeps {
|
||||
if client == nil {
|
||||
return agentmodel.QuickTaskDeps{}
|
||||
}
|
||||
adapter := &taskRPCAdapter{client: client}
|
||||
return agentmodel.QuickTaskDeps{
|
||||
CreateTask: adapter.CreateTask,
|
||||
QueryTasks: adapter.QueryTasks,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateTask 通过 task zrpc 创建四象限任务,返回 task_id。
|
||||
func (a *taskRPCAdapter) CreateTask(
|
||||
userID int,
|
||||
title string,
|
||||
priorityGroup int,
|
||||
estimatedSections int,
|
||||
deadlineAt *time.Time,
|
||||
urgencyThresholdAt *time.Time,
|
||||
) (int, error) {
|
||||
if a == nil || a.client == nil {
|
||||
return 0, errors.New("task rpc client is nil")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), quickTaskCreateRPCTimeout)
|
||||
defer cancel()
|
||||
|
||||
// 调用目的:把 quick_task 节点产出的结构化任务写入 task 服务,避免 agent 继续直连 tasks 表。
|
||||
raw, err := a.client.AddTask(ctx, taskcontracts.AddTaskRequest{
|
||||
UserID: userID,
|
||||
Title: strings.TrimSpace(title),
|
||||
PriorityGroup: priorityGroup,
|
||||
EstimatedSections: estimatedSections,
|
||||
DeadlineAt: deadlineAt,
|
||||
UrgencyThresholdAt: urgencyThresholdAt,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var resp taskcontracts.AddTaskResponse
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if resp.ID <= 0 {
|
||||
return 0, errors.New("task rpc add task returned invalid task id")
|
||||
}
|
||||
return resp.ID, nil
|
||||
}
|
||||
|
||||
// QueryTasks 通过 task zrpc 读取用户任务,再复用 agent 侧既有过滤、排序和展示转换语义。
|
||||
func (a *taskRPCAdapter) QueryTasks(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
params agentmodel.TaskQueryParams,
|
||||
) ([]agentmodel.TaskQueryResult, error) {
|
||||
if a == nil || a.client == nil {
|
||||
return nil, errors.New("task rpc client is nil")
|
||||
}
|
||||
raw, err := a.client.GetUserTasks(ctx, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, respond.UserTasksEmpty) {
|
||||
return []agentmodel.TaskQueryResult{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var items []taskcontracts.TaskListItem
|
||||
if len(raw) > 0 && string(raw) != "null" {
|
||||
if err := json.Unmarshal(raw, &items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
tasks := taskListItemsToModels(items)
|
||||
|
||||
req := agentmodel.TaskQueryRequest{
|
||||
UserID: userID,
|
||||
Quadrant: params.Quadrant,
|
||||
SortBy: params.SortBy,
|
||||
Order: params.Order,
|
||||
Limit: params.Limit,
|
||||
IncludeCompleted: params.IncludeCompleted,
|
||||
Keyword: params.Keyword,
|
||||
DeadlineBefore: params.DeadlineBefore,
|
||||
DeadlineAfter: params.DeadlineAfter,
|
||||
}
|
||||
|
||||
filtered := make([]model.Task, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
if !taskMatchesQueryFilter(task, req) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, task)
|
||||
}
|
||||
|
||||
sortTasksForQuery(filtered, req)
|
||||
if req.Limit > 0 && len(filtered) > req.Limit {
|
||||
filtered = filtered[:req.Limit]
|
||||
}
|
||||
return taskModelsToQueryResults(filtered), nil
|
||||
}
|
||||
|
||||
func taskListItemsToModels(items []taskcontracts.TaskListItem) []model.Task {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]model.Task, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, model.Task{
|
||||
ID: item.ID,
|
||||
UserID: item.UserID,
|
||||
Title: item.Title,
|
||||
Priority: item.PriorityGroup,
|
||||
EstimatedSections: model.NormalizeEstimatedSections(&item.EstimatedSections),
|
||||
IsCompleted: item.IsCompleted,
|
||||
DeadlineAt: parseTaskListTime(item.Deadline),
|
||||
UrgencyThresholdAt: parseTaskListTime(item.UrgencyThresholdAt),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func taskModelsToQueryResults(tasks []model.Task) []agentmodel.TaskQueryResult {
|
||||
if len(tasks) == 0 {
|
||||
return []agentmodel.TaskQueryResult{}
|
||||
}
|
||||
results := make([]agentmodel.TaskQueryResult, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
deadlineStr := ""
|
||||
if task.DeadlineAt != nil {
|
||||
deadlineStr = task.DeadlineAt.In(time.Local).Format("2006-01-02 15:04")
|
||||
}
|
||||
results = append(results, agentmodel.TaskQueryResult{
|
||||
ID: task.ID,
|
||||
Title: task.Title,
|
||||
PriorityGroup: task.Priority,
|
||||
EstimatedSections: model.NormalizeEstimatedSections(&task.EstimatedSections),
|
||||
IsCompleted: task.IsCompleted,
|
||||
DeadlineAt: deadlineStr,
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func parseTaskListTime(value string) *time.Time {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05", "2006-01-02 15:04"} {
|
||||
parsed, err := time.ParseInLocation(layout, value, time.Local)
|
||||
if err == nil {
|
||||
return &parsed
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
145
backend/services/agent/sv/token_meter.go
Normal file
145
backend/services/agent/sv/token_meter.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
einoCallbacks "github.com/cloudwego/eino/callbacks"
|
||||
einoModel "github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
templatecb "github.com/cloudwego/eino/utils/callbacks"
|
||||
)
|
||||
|
||||
type requestTokenMeterCtxKey struct{}
|
||||
|
||||
// RequestTokenMeter 是“单次请求级”的 token 统计容器。
|
||||
//
|
||||
// 设计目标:
|
||||
// 1. 聚合本次请求内所有模型调用 token(路由/图节点/流式主对话);
|
||||
// 2. 线程安全,允许在同一请求内被多个链路节点并发累加;
|
||||
// 3. 最终由服务层一次性读取快照并写入持久化。
|
||||
type RequestTokenMeter struct {
|
||||
mu sync.Mutex
|
||||
|
||||
promptTokens int
|
||||
completionTokens int
|
||||
totalTokens int
|
||||
}
|
||||
|
||||
// RequestTokenMeterSnapshot 是 RequestTokenMeter 的只读快照。
|
||||
type RequestTokenMeterSnapshot struct {
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
TotalTokens int
|
||||
}
|
||||
|
||||
var registerTokenMeterCallbackOnce sync.Once
|
||||
|
||||
// ensureTokenMeterCallbackRegistered 注册一次全局 ChatModel callback。
|
||||
//
|
||||
// 说明:
|
||||
// 1. callback 只负责“采集并累加 token”,不做业务决策;
|
||||
// 2. 仅当 ctx 里存在 RequestTokenMeter 时才会生效;
|
||||
// 3. 采用 once,避免在测试/多次构造服务时重复注册。
|
||||
func ensureTokenMeterCallbackRegistered() {
|
||||
registerTokenMeterCallbackOnce.Do(func() {
|
||||
handler := templatecb.NewHandlerHelper().
|
||||
ChatModel(&templatecb.ModelCallbackHandler{
|
||||
OnEnd: func(ctx context.Context, _ *einoCallbacks.RunInfo, output *einoModel.CallbackOutput) context.Context {
|
||||
if output == nil || output.TokenUsage == nil {
|
||||
return ctx
|
||||
}
|
||||
addModelUsageIntoRequest(ctx, output.TokenUsage)
|
||||
return ctx
|
||||
},
|
||||
}).
|
||||
Handler()
|
||||
einoCallbacks.AppendGlobalHandlers(handler)
|
||||
})
|
||||
}
|
||||
|
||||
// withRequestTokenMeter 创建并挂载“请求级 token 统计器”。
|
||||
func withRequestTokenMeter(ctx context.Context) (context.Context, *RequestTokenMeter) {
|
||||
meter := &RequestTokenMeter{}
|
||||
return context.WithValue(ctx, requestTokenMeterCtxKey{}, meter), meter
|
||||
}
|
||||
|
||||
// getRequestTokenMeter 读取请求上下文中的 token 统计器。
|
||||
func getRequestTokenMeter(ctx context.Context) *RequestTokenMeter {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
meter, _ := ctx.Value(requestTokenMeterCtxKey{}).(*RequestTokenMeter)
|
||||
return meter
|
||||
}
|
||||
|
||||
// addSchemaUsageIntoRequest 把 schema usage 累加到请求级统计器。
|
||||
func addSchemaUsageIntoRequest(ctx context.Context, usage *schema.TokenUsage) {
|
||||
if usage == nil {
|
||||
return
|
||||
}
|
||||
addTokenUsageValues(ctx, usage.PromptTokens, usage.CompletionTokens, normalizeUsageTotal(usage.TotalTokens, usage.PromptTokens, usage.CompletionTokens))
|
||||
}
|
||||
|
||||
// addModelUsageIntoRequest 把 Eino model callback usage 累加到请求级统计器。
|
||||
func addModelUsageIntoRequest(ctx context.Context, usage *einoModel.TokenUsage) {
|
||||
if usage == nil {
|
||||
return
|
||||
}
|
||||
addTokenUsageValues(ctx, usage.PromptTokens, usage.CompletionTokens, normalizeUsageTotal(usage.TotalTokens, usage.PromptTokens, usage.CompletionTokens))
|
||||
}
|
||||
|
||||
// addTokenUsageValues 统一累加 token 数值。
|
||||
func addTokenUsageValues(ctx context.Context, promptTokens, completionTokens, totalTokens int) {
|
||||
meter := getRequestTokenMeter(ctx)
|
||||
if meter == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if promptTokens < 0 {
|
||||
promptTokens = 0
|
||||
}
|
||||
if completionTokens < 0 {
|
||||
completionTokens = 0
|
||||
}
|
||||
if totalTokens < 0 {
|
||||
totalTokens = 0
|
||||
}
|
||||
|
||||
meter.mu.Lock()
|
||||
defer meter.mu.Unlock()
|
||||
meter.promptTokens += promptTokens
|
||||
meter.completionTokens += completionTokens
|
||||
meter.totalTokens += totalTokens
|
||||
}
|
||||
|
||||
// snapshotRequestTokenMeter 获取请求级 token 统计快照。
|
||||
func snapshotRequestTokenMeter(ctx context.Context) RequestTokenMeterSnapshot {
|
||||
meter := getRequestTokenMeter(ctx)
|
||||
if meter == nil {
|
||||
return RequestTokenMeterSnapshot{}
|
||||
}
|
||||
meter.mu.Lock()
|
||||
defer meter.mu.Unlock()
|
||||
return RequestTokenMeterSnapshot{
|
||||
PromptTokens: meter.promptTokens,
|
||||
CompletionTokens: meter.completionTokens,
|
||||
TotalTokens: meter.totalTokens,
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeUsageTotal 统一 total token 口径。
|
||||
//
|
||||
// 规则:
|
||||
// 1. 模型返回 total>0 时优先使用 total;
|
||||
// 2. total 缺失时使用 prompt+completion 回退。
|
||||
func normalizeUsageTotal(totalTokens, promptTokens, completionTokens int) int {
|
||||
if totalTokens > 0 {
|
||||
return totalTokens
|
||||
}
|
||||
sum := promptTokens + completionTokens
|
||||
if sum < 0 {
|
||||
return 0
|
||||
}
|
||||
return sum
|
||||
}
|
||||
754
backend/services/agent/tools/SCHEDULE_TOOLS.md
Normal file
754
backend/services/agent/tools/SCHEDULE_TOOLS.md
Normal file
@@ -0,0 +1,754 @@
|
||||
# 日程工具设计文档
|
||||
|
||||
> 本文档定义了 agent 日程调度场景下的工具层设计。
|
||||
> 工具是 LLM 与日程数据之间的唯一边界——LLM 只能通过工具的输入/输出与日程交互,永远不直接接触原始数据。
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计原则
|
||||
|
||||
1. **工具即边界**:LLM 通过工具感知和修改日程,不直接接触 state 或数据库
|
||||
2. **自然语言返回**:工具返回值为自然语言 + 轻结构(缩进、列表),LLM 直接理解
|
||||
3. **只报事实,不做判断**:读工具只报当前真实状态,不附建议/推荐/假设;写工具只报变更后的事实
|
||||
4. **操作前自动校验**:写工具在执行前自动检测冲突和锁定,失败时 state 不变
|
||||
5. **State 内操作**:所有写工具只修改内存中的 state,不直接写库;整个方案完成后由 Confirm 节点统一写库
|
||||
|
||||
---
|
||||
|
||||
## 2. 索引体系
|
||||
|
||||
LLM 不接触真实的日期和星期,使用两级整数索引:
|
||||
|
||||
- **天索引(day)**:规划窗口内的天数编号,从 1 开始连续递增
|
||||
- 例如:规划窗口为第1周周三至第3周周一,共13天,编号为第1天~第13天
|
||||
- 工具层负责 day ↔ 真实日期 的映射
|
||||
- 规划窗口由 Plan 节点向用户确认,不明确时走 ask_user
|
||||
|
||||
- **时段索引(slot)**:每天内的节课编号,范围 1-12
|
||||
- 标准节次:1-2, 3-4, 5-6, 7-8, 9-10, 11-12(共6个标准段)
|
||||
- 连堂课可能跨越:1-3(3连堂)、1-4(4连堂)、9-12(4连堂)等
|
||||
- 任务时段用 (slot_start, slot_end) 表示,例如 (1, 4) = 第1-4节
|
||||
|
||||
- **任务定位**:day + slot_start + slot_end,例如 (3, 1, 4) = 第3天第1-4节
|
||||
|
||||
---
|
||||
|
||||
## 3. State 数据结构
|
||||
|
||||
State 是工具层的操作对象,存在于内存中,不直接暴露给 LLM。
|
||||
|
||||
### 3.1 整体结构
|
||||
|
||||
```json
|
||||
{
|
||||
"window": {
|
||||
"total_days": 13,
|
||||
"day_mapping": [
|
||||
{ "day_index": 1, "week": 5, "day_of_week": 1 },
|
||||
{ "day_index": 2, "week": 5, "day_of_week": 2 },
|
||||
{ "day_index": 3, "week": 5, "day_of_week": 3 },
|
||||
{ "day_index": 4, "week": 5, "day_of_week": 4 },
|
||||
{ "day_index": 5, "week": 5, "day_of_week": 5 },
|
||||
{ "day_index": 6, "week": 5, "day_of_week": 6 },
|
||||
{ "day_index": 7, "week": 5, "day_of_week": 7 },
|
||||
{ "day_index": 8, "week": 6, "day_of_week": 1 },
|
||||
{ "day_index": 9, "week": 6, "day_of_week": 2 },
|
||||
{ "day_index": 10, "week": 6, "day_of_week": 3 },
|
||||
{ "day_index": 11, "week": 6, "day_of_week": 4 },
|
||||
{ "day_index": 12, "week": 6, "day_of_week": 5 },
|
||||
{ "day_index": 13, "week": 6, "day_of_week": 6 }
|
||||
]
|
||||
},
|
||||
"tasks": [
|
||||
{
|
||||
"state_id": 1,
|
||||
"source": "event",
|
||||
"source_id": 101,
|
||||
"name": "高等数学",
|
||||
"category": "课程",
|
||||
"status": "existing",
|
||||
"locked": true,
|
||||
"slots": [
|
||||
{ "day": 1, "slot_start": 1, "slot_end": 2 },
|
||||
{ "day": 4, "slot_start": 1, "slot_end": 2 },
|
||||
{ "day": 8, "slot_start": 1, "slot_end": 2 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"state_id": 2,
|
||||
"source": "event",
|
||||
"source_id": 102,
|
||||
"name": "思政(水课)",
|
||||
"category": "课程",
|
||||
"status": "existing",
|
||||
"locked": false,
|
||||
"can_embed": true,
|
||||
"slots": [
|
||||
{ "day": 2, "slot_start": 1, "slot_end": 2 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"state_id": 3,
|
||||
"source": "task_item",
|
||||
"source_id": 201,
|
||||
"name": "复习线代",
|
||||
"category": "学习",
|
||||
"status": "pending",
|
||||
"duration": 3,
|
||||
"category_id": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 字段说明
|
||||
|
||||
**任务通用字段:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `state_id` | int | State 内唯一 ID(递增),工具层和 LLM 使用此 ID 交互 |
|
||||
| `source` | string | 数据来源:`"event"` = 来自 ScheduleEvent,`"task_item"` = 来自 TaskClassItem |
|
||||
| `source_id` | int | 原表主键(ScheduleEvent.ID 或 TaskClassItem.ID),写库时用于反查 |
|
||||
| `name` | string | 任务名称,来自 ScheduleEvent.Name 或 TaskClassItem.Content |
|
||||
| `category` | string | 类别名,来自 TaskClass.Name(如"课程"、"学习"、"作业") |
|
||||
| `status` | string | `"existing"`(已安排/已确定)| `"suggested"`(已预排/可优化)| `"pending"`(待安排)|
|
||||
| `locked` | bool | 是否锁定。推导规则:ScheduleEvent.Type="course" 且 CanBeEmbed=false 时为 true |
|
||||
| `slots` | array | 已安排任务的时段列表,每项含 day/slot_start/slot_end |
|
||||
| `duration` | int | 待安排/已预排任务需要的连续时段数(pending / suggested 任务常见) |
|
||||
| `category_id` | int | 所属 TaskClass 的 ID(仅 source=task_item 时有值) |
|
||||
|
||||
**嵌入任务相关字段(仅 can_embed=true 的任务):**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `can_embed` | bool | 该时段是否允许嵌入其他任务,来自 ScheduleEvent.CanBeEmbedded |
|
||||
| `embedded_by` | int | 被哪个 state_id 的任务嵌入(宿主视角) |
|
||||
| `embed_host` | int | 嵌入到哪个 state_id 的时段里(嵌入任务视角) |
|
||||
|
||||
### 3.3 数据来源与映射
|
||||
|
||||
**existing 任务(从数据库加载):**
|
||||
|
||||
| State 字段 | 数据库来源 |
|
||||
|-----------|-----------|
|
||||
| source_id | ScheduleEvent.ID |
|
||||
| name | ScheduleEvent.Name |
|
||||
| category | ScheduleEvent.Type("course"→"课程","task"→取关联 TaskClass.Name) |
|
||||
| locked | ScheduleEvent.Type="course" 且 CanBeEmbedded=false |
|
||||
| can_embed | ScheduleEvent.CanBeEmbedded |
|
||||
| slots | 查 Schedule 表(WHERE event_id=? AND week/day_of_week IN 窗口范围),按 section 连续段压缩 |
|
||||
|
||||
**pending 任务(从数据库加载):**
|
||||
|
||||
| State 字段 | 数据库来源 |
|
||||
|-----------|-----------|
|
||||
| source_id | TaskClassItem.ID |
|
||||
| name | TaskClassItem.Content |
|
||||
| category | 关联 TaskClass.Name(通过 CategoryID) |
|
||||
| duration | 由 TaskClass.TotalSlots / Item 数量推算,或固定为 2 |
|
||||
| category_id | TaskClassItem.CategoryID |
|
||||
|
||||
### 3.4 Section 压缩/解压
|
||||
|
||||
数据库中 Schedule 表逐节存储(每节一条记录),State 中压缩为连续范围:
|
||||
|
||||
```
|
||||
DB 记录:
|
||||
Schedule(event_id=101, week=5, day_of_week=1, section=1)
|
||||
Schedule(event_id=101, week=5, day_of_week=1, section=2)
|
||||
|
||||
压缩为 State:
|
||||
{ "day": 1, "slot_start": 1, "slot_end": 2 }
|
||||
```
|
||||
|
||||
反向操作(写库时):将 slot_start/slot_end 展开为逐条 Schedule 记录插入。
|
||||
|
||||
### 3.5 Day 映射
|
||||
|
||||
工具层通过 day_mapping 数组完成 day_index ↔ (week, day_of_week) 的双向转换:
|
||||
|
||||
- **读操作**:从 Schedule 表查到 (week=5, day_of_week=1, section=1),通过 day_mapping 反查 day_index=1
|
||||
- **写操作**:LLM 指定 day=3,通过 day_mapping 查到 (week=5, day_of_week=3),用于构造 Schedule 记录
|
||||
- **规划窗口**:由 Plan 节点确认范围,工具层初始化时生成 day_mapping
|
||||
|
||||
---
|
||||
|
||||
## 4. 读工具
|
||||
|
||||
### 4.1 get_overview
|
||||
|
||||
获取规划窗口总览(任务视角,全量返回)。
|
||||
|
||||
行为约束:
|
||||
- 保留课程占位统计(例如“第1天:占2/12”),避免误判可用空间。
|
||||
- 每日明细只展开任务(非课程),课程不进入任务明细列表。
|
||||
- 在当前阶段(窗口通常不超过 30 天)直接全量返回,不做截断。
|
||||
|
||||
**入参:** 无
|
||||
|
||||
**返回示例:**
|
||||
|
||||
```
|
||||
规划窗口共13天,每天12个时段,总计156个时段。
|
||||
当前已占用48个,空闲108个。课程占位条目7个(仅用于占位统计);任务条目:已安排(existing)1个、已预排(suggested)2个、待安排(pending)3个。
|
||||
|
||||
每日概况:
|
||||
第1天:总占6/12(课程占6/12,任务占0/12) — 任务:无
|
||||
第2天:总占2/12(课程占2/12,任务占0/12) — 任务:无
|
||||
第3天:总占2/12(课程占0/12,任务占2/12) — 任务:[35]第一章随机事件与概率(suggested,第5-6节)
|
||||
第4天:总占4/12(课程占2/12,任务占2/12) — 任务:[36]第二章随机变量(suggested,第7-8节)
|
||||
...
|
||||
|
||||
任务清单(全量,已过滤课程):
|
||||
[35]第一章随机事件与概率 | 状态:suggested | 类别:概率论 | 时段:第3天(5-6节)
|
||||
[36]第二章随机变量 | 状态:suggested | 类别:概率论 | 时段:第4天(7-8节)
|
||||
[37]第三章多维随机变量 | 状态:pending | 类别:概率论 | 需2个连续时段
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.2 query_range
|
||||
|
||||
查看某天(或某天某段)的细粒度占用详情。
|
||||
|
||||
**入参:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| day | int | 是 | 天索引 |
|
||||
| slot_start | int | 否 | 起始节次,不传则返回整天 |
|
||||
| slot_end | int | 否 | 结束节次,不传则返回整天 |
|
||||
|
||||
**返回示例(查整天):**
|
||||
|
||||
```
|
||||
第4天 全天:
|
||||
|
||||
第1-2节:[1]高等数学(固定)
|
||||
第3-4节:[6]线代
|
||||
第5-6节:空
|
||||
第7-8节:空
|
||||
第9-10节:[8]程序设计
|
||||
第11-12节:空
|
||||
|
||||
连续空闲区:第5-8节(4时段)、第11-12节(2时段)
|
||||
可嵌入:第1-2节已有[1]高等数学(固定,不可嵌入)
|
||||
```
|
||||
|
||||
**返回示例(查具体范围):**
|
||||
|
||||
```
|
||||
第4天 第5-8节:
|
||||
|
||||
第5节:空
|
||||
第6节:空
|
||||
第7节:空
|
||||
第8节:空
|
||||
|
||||
该范围4个时段全部空闲。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.3 query_available_slots
|
||||
|
||||
查询候选坑位池(结构化返回):默认先返回“纯空位”,不足时再补“可嵌入位”。
|
||||
|
||||
**入参:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| span / duration | int | 否 | 目标连续时段长度,默认 2 |
|
||||
| limit | int | 否 | 返回候选上限,默认 12 |
|
||||
| allow_embed | bool | 否 | 是否允许补可嵌入位,默认 true |
|
||||
| day / day_start / day_end | int | 否 | 天级范围过滤(`day` 与区间互斥) |
|
||||
| day_scope | string | 否 | `all` / `workday` / `weekend` |
|
||||
| day_of_week | []int | 否 | 星期过滤(1-7) |
|
||||
| week / week_filter / week_from / week_to | int / []int | 否 | 周级过滤 |
|
||||
| slot_type / slot_types | string / []string | 否 | `pure/empty/strict` 会强制只返回纯空位 |
|
||||
| exclude_sections | []int | 否 | 排除节次(1-12) |
|
||||
| after_section / before_section | int | 否 | 只返回区间之后/之前的候选 |
|
||||
| section_from + section_to | int | 否 | 精确节次区间查询(需同时提供) |
|
||||
|
||||
**返回示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "query_available_slots",
|
||||
"count": 12,
|
||||
"strict_count": 8,
|
||||
"embedded_count": 4,
|
||||
"fallback_used": true,
|
||||
"day_scope": "all",
|
||||
"day_of_week": [],
|
||||
"week_filter": [12],
|
||||
"week_from": 12,
|
||||
"week_to": 12,
|
||||
"span": 2,
|
||||
"allow_embed": true,
|
||||
"exclude_sections": [],
|
||||
"slots": [
|
||||
{
|
||||
"day": 5,
|
||||
"week": 12,
|
||||
"day_of_week": 3,
|
||||
"slot_start": 1,
|
||||
"slot_end": 2,
|
||||
"slot_type": "empty"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.4 list_tasks
|
||||
|
||||
列出任务清单,可按类别和状态过滤。
|
||||
|
||||
**入参:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| category | string | 否 | 过滤类别(对应 TaskClass.Name,如"课程"、"学习";不支持 task_class_ids 列表) |
|
||||
| status | string | 否 | existing / suggested / pending / all,默认 all(仅支持单值,不支持 `existing,suggested` 这类拼接) |
|
||||
|
||||
**返回示例(待安排):**
|
||||
|
||||
```
|
||||
待安排任务共3个:
|
||||
|
||||
[3]复习线代 — 需3个连续时段,类别:学习
|
||||
[7]写实验报告 — 需2个连续时段,类别:作业
|
||||
[9]小组讨论 — 需2个连续时段,类别:学习
|
||||
```
|
||||
|
||||
**返回示例(全部):**
|
||||
|
||||
```
|
||||
共9个任务,已安排6个,待安排3个。
|
||||
|
||||
已安排:
|
||||
[1]高等数学(课程,固定) — 第1天(1-2节) 第4天(1-2节) 第8天(1-2节)
|
||||
[2]英语(课程) — 第1天(3-4节) 第6天(1-2节)
|
||||
[4]体育(课程) — 第1天(5-6节)
|
||||
[5]物理(课程) — 第2天(3-4节) 第8天(3-4节)
|
||||
[6]线代(学习) — 第4天(3-4节)
|
||||
[8]程序设计(课程) — 第4天(9-10节)
|
||||
[10]思政(课程,可嵌入) — 第7天(1-2节)
|
||||
|
||||
待安排:
|
||||
[3]复习线代(学习) — 需3时段
|
||||
[7]写实验报告(作业) — 需2时段
|
||||
[9]小组讨论(学习) — 需2时段
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.5 get_task_info
|
||||
|
||||
查询单个任务的详细信息。
|
||||
|
||||
**入参:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| task_id | int | 是 | 任务 ID |
|
||||
|
||||
**返回示例(普通任务):**
|
||||
|
||||
```
|
||||
[1]高等数学
|
||||
类别:课程 | 状态:已安排(固定)
|
||||
来源:课程表
|
||||
占用时段:
|
||||
第1天 第1-2节
|
||||
第4天 第1-2节
|
||||
第8天 第1-2节
|
||||
```
|
||||
|
||||
**返回示例(可嵌入任务):**
|
||||
|
||||
```
|
||||
[10]思政
|
||||
类别:课程 | 状态:已安排
|
||||
来源:课程表
|
||||
可嵌入:是(允许在此时段嵌入其他任务)
|
||||
占用时段:
|
||||
第7天 第1-2节
|
||||
当前嵌入任务:无
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 写工具
|
||||
|
||||
### 5.1 place
|
||||
|
||||
将待安排任务预排到指定位置。
|
||||
|
||||
**入参:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| task_id | int | 是 | 待安排任务的 ID |
|
||||
| day | int | 是 | 目标天索引 |
|
||||
| slot_start | int | 是 | 目标起始节次 |
|
||||
|
||||
**成功返回:**
|
||||
|
||||
```
|
||||
已将 [3]复习线代 放到第5天第1-3节。
|
||||
第5天当前占用:[3]复习线代(1-3节),占用3/12。
|
||||
待安排任务剩余:2个。
|
||||
```
|
||||
|
||||
**失败返回(冲突):**
|
||||
|
||||
```
|
||||
放置失败:第5天第1-2节已被 [4]体育 占用。
|
||||
第5天当前占用:[4]体育(1-4节),占用4/12。空闲时段:第5-12节。
|
||||
```
|
||||
|
||||
**失败返回(状态错误):**
|
||||
|
||||
```
|
||||
放置失败:[1]高等数学 不是待安排任务,无法放置。
|
||||
```
|
||||
|
||||
**成功返回(嵌入到水课):**
|
||||
|
||||
```
|
||||
已将 [7]写实验报告 嵌入到第7天第1-2节(宿主:[10]思政)。
|
||||
第7天当前占用:[10]思政(1-2节) [7]写实验报告(嵌入1-2节),占用2/12。
|
||||
待安排任务剩余:2个。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.2 move
|
||||
|
||||
移动已预排任务(仅 suggested)到新位置。
|
||||
|
||||
**入参:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| task_id | int | 是 | 任务 ID |
|
||||
| new_day | int | 是 | 目标天索引 |
|
||||
| new_slot_start | int | 是 | 目标起始节次 |
|
||||
|
||||
**成功返回:**
|
||||
|
||||
```
|
||||
已将 [6]线代 从第4天第3-4节移至第9天第1-2节。
|
||||
第4天当前占用:[1]高等数学(1-2节) [8]程序设计(9-10节),占用4/12。
|
||||
第9天当前占用:[6]线代(1-2节),占用2/12。
|
||||
```
|
||||
|
||||
**失败返回(冲突):**
|
||||
|
||||
```
|
||||
移动失败:第9天第1-2节已被 [9]小组讨论 占用。
|
||||
第9天当前占用:[9]小组讨论(1-2节),占用2/12。空闲时段:第3-12节。
|
||||
```
|
||||
|
||||
**失败返回(锁定):**
|
||||
|
||||
```
|
||||
移动失败:[1]高等数学 是固定课程,不可移动。
|
||||
```
|
||||
|
||||
**失败返回(状态错误):**
|
||||
|
||||
```
|
||||
移动失败:[3]复习线代 当前为待安排状态,请使用 place 放置。
|
||||
```
|
||||
|
||||
```
|
||||
移动失败:[2]英语 当前为已安排(existing)任务,不允许 move;仅 suggested 任务可移动。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.3 swap
|
||||
|
||||
交换两个已落位任务的位置。
|
||||
|
||||
**入参:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| task_a | int | 是 | 任务 A 的 ID |
|
||||
| task_b | int | 是 | 任务 B 的 ID |
|
||||
|
||||
**成功返回:**
|
||||
|
||||
```
|
||||
交换完成:
|
||||
[2]英语:第1天第3-4节 → 第6天第1-2节
|
||||
[6]线代:第6天第1-2节 → 第1天第3-4节
|
||||
第1天当前占用:[1]高等数学(1-2节) [6]线代(3-4节) [4]体育(5-6节),占用6/12。
|
||||
第6天当前占用:[2]英语(1-2节),占用2/12。
|
||||
```
|
||||
|
||||
**失败返回(时长不匹配):**
|
||||
|
||||
```
|
||||
交换失败:[5]物理 占4个时段,[2]英语 占2个时段,时长不同无法直接交换。
|
||||
```
|
||||
|
||||
**失败返回(任一任务锁定):**
|
||||
|
||||
```
|
||||
交换失败:[1]高等数学 是固定课程,不可交换。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.4 batch_move
|
||||
|
||||
批量原子移动多个任务(仅 suggested,**单次最多 2 条**),要么全部成功,要么全部回滚。
|
||||
|
||||
**入参:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| moves | array | 是 | 每项包含 task_id, new_day, new_slot_start |
|
||||
|
||||
**成功返回:**
|
||||
|
||||
```
|
||||
批量移动完成,2个任务全部成功:
|
||||
[2]英语 → 第3天第1-2节
|
||||
[6]线代 → 第5天第3-4节
|
||||
第3天当前占用:[2]英语(1-2节),占用2/12。
|
||||
第5天当前占用:[6]线代(3-4节),占用2/12。
|
||||
```
|
||||
|
||||
**失败返回(超出上限):**
|
||||
|
||||
```
|
||||
批量移动失败:当前最多支持 2 条移动请求。请改用队列化逐项处理(queue_pop_head + queue_apply_head_move)。
|
||||
```
|
||||
|
||||
**失败返回:**
|
||||
|
||||
```
|
||||
批量移动失败,全部回滚,无任何变更。
|
||||
冲突:[6]线代 → 第5天第3-4节,该位置已被 [3]复习线代(1-3节) 占用。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.5 unplace
|
||||
|
||||
将已落位任务恢复为待安排状态。
|
||||
|
||||
**入参:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| task_id | int | 是 | 任务 ID |
|
||||
|
||||
**成功返回:**
|
||||
|
||||
```
|
||||
已将 [3]复习线代 从第5天第1-3节移除,恢复为待安排状态。
|
||||
第5天当前占用:0/12。
|
||||
待安排任务剩余:1个。
|
||||
```
|
||||
|
||||
**失败返回(锁定):**
|
||||
|
||||
```
|
||||
移除失败:[1]高等数学 是固定课程,不可移除。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.6 min_context_switch
|
||||
|
||||
在给定任务集合内重排 suggested 任务,尽量把同类任务排成连续块,以减少上下文切换。
|
||||
|
||||
使用约束:
|
||||
- 仅在用户明确说明“允许打乱顺序”时调用。
|
||||
- 仅支持 suggested 且已落位任务。
|
||||
- 工具只在传入集合内部重排,不会主动改动集合外任务。
|
||||
|
||||
**入参:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| task_ids | array[int] | 是 | 参与重排的任务 ID 列表(至少 2 个) |
|
||||
| task_id | int | 否 | 兼容单值参数,不建议新调用使用 |
|
||||
|
||||
**成功返回:**
|
||||
|
||||
```
|
||||
最少上下文切换重排完成:共处理 6 个任务,上下文切换次数 5 -> 2。
|
||||
本次调整:
|
||||
[35]概率第一章:第3天(星期3)第1-2节 -> 第2天(星期2)第5-6节
|
||||
[41]概率第二章:第4天(星期4)第1-2节 -> 第3天(星期3)第1-2节
|
||||
第2天当前占用:...
|
||||
第3天当前占用:...
|
||||
第4天当前占用:...
|
||||
```
|
||||
|
||||
**失败返回(未授权顺序重排时应由上层拦截):**
|
||||
|
||||
```
|
||||
已拒绝执行 min_context_switch:当前未授权打乱顺序。如需使用该工具,请先由用户明确说明“允许打乱顺序”。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.7 queue_pop_head
|
||||
|
||||
弹出并返回当前队首任务;若已有 current 则复用,保证一次只处理一个任务。
|
||||
|
||||
**入参:**
|
||||
|
||||
无
|
||||
|
||||
**返回示例:**
|
||||
|
||||
```json
|
||||
{"tool":"queue_pop_head","has_head":true,"pending_count":5,"current":{"task_id":35,"name":"示例任务","status":"suggested","slots":[{"day":3,"week":12,"day_of_week":1,"slot_start":5,"slot_end":6}]}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.8 queue_apply_head_move
|
||||
|
||||
将当前队首任务移动到指定位置并自动出队。仅作用于 current,不接受 task_id。
|
||||
|
||||
**入参:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| new_day | int | 是 | 目标 day |
|
||||
| new_slot_start | int | 是 | 目标起始节次 |
|
||||
|
||||
**返回示例:**
|
||||
|
||||
```json
|
||||
{"tool":"queue_apply_head_move","success":true,"task_id":35,"pending_count":4,"completed_count":2,"result":"已将 [35]... 从第3天第5-6节移至第5天第3-4节。"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.9 queue_skip_head
|
||||
|
||||
跳过当前队首任务(不改日程),标记为 skipped 并继续后续队列。
|
||||
|
||||
**入参:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| reason | string | 否 | 跳过原因 |
|
||||
|
||||
**返回示例:**
|
||||
|
||||
```json
|
||||
{"tool":"queue_skip_head","success":true,"skipped_task_id":35,"pending_count":4,"skipped_count":1}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.10 queue_status
|
||||
|
||||
查看当前待处理队列状态(pending/current/completed/skipped)。
|
||||
|
||||
**入参:**
|
||||
|
||||
无
|
||||
|
||||
**返回示例:**
|
||||
|
||||
```json
|
||||
{"tool":"queue_status","pending_count":5,"completed_count":1,"skipped_count":0,"current_task_id":35,"current_attempt":1}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.11 spread_even
|
||||
|
||||
在给定任务集合内执行“均匀化铺开”:
|
||||
先按筛选条件收集候选坑位,再用确定性规划器生成移动方案并原子提交。
|
||||
|
||||
**入参:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| task_ids | array[int] | 是 | 参与均匀化的任务 ID 列表(至少 2 个) |
|
||||
| task_id | int | 否 | 兼容单值参数,不建议新调用使用 |
|
||||
| day/day_start/day_end | int | 否 | 天级范围过滤 |
|
||||
| day_scope | string | 否 | `all` / `workday` / `weekend` |
|
||||
| day_of_week | array[int] | 否 | 星期过滤(1~7) |
|
||||
| week/week_filter/week_from/week_to | int/array[int] | 否 | 周级过滤 |
|
||||
| limit | int | 否 | 每个跨度的候选坑位上限(内部会按任务数自动放大) |
|
||||
| allow_embed | bool | 否 | 是否允许补充可嵌入位,默认 true |
|
||||
| exclude_sections | array[int] | 否 | 排除节次 |
|
||||
| after_section/before_section | int | 否 | 节次边界过滤 |
|
||||
|
||||
**成功返回:**
|
||||
|
||||
```
|
||||
均匀化调整完成:共处理 6 个任务,候选坑位 24 个。
|
||||
本次调整:
|
||||
[35]第一章复习:第3天(星期3)第5-6节 -> 第5天(星期5)第1-2节
|
||||
[41]第二章练习:第4天(星期4)第5-6节 -> 第6天(星期6)第1-2节
|
||||
第5天当前占用:...
|
||||
第6天当前占用:...
|
||||
```
|
||||
|
||||
**失败返回(候选不足):**
|
||||
|
||||
```
|
||||
均匀化调整失败:跨度=2 可用坑位不足:required=4, got=2。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 公共规则
|
||||
|
||||
### 冲突检测
|
||||
- 所有写操作执行前自动检测目标位置是否冲突
|
||||
- 冲突时拒绝操作,返回冲突任务名称和占用节次
|
||||
- state 保持不变
|
||||
|
||||
### 锁定保护
|
||||
- locked=true 的任务,move / swap / unplace 直接拒绝
|
||||
- place 新任务到锁定时段同样拒绝
|
||||
|
||||
### 状态约束
|
||||
- pending 任务只能 place,不能 move / swap / unplace
|
||||
- suggested 任务可以 move / swap / unplace / spread_even / min_context_switch
|
||||
- existing 任务不能 move / batch_move / spread_even / min_context_switch(仅作已安排事实层)
|
||||
- 状态不符时返回明确错误信息
|
||||
|
||||
### 返回格式
|
||||
- 返回值为自然语言 + 轻结构(缩进、列表)
|
||||
- 占用信息始终附带每个任务的具体节次范围
|
||||
- 读工具只报当前真实状态,不做假设
|
||||
- 写工具只报变更后的事实,不附建议
|
||||
|
||||
### ID 规范
|
||||
- LLM 可见的任务 ID 为 `state_id`(递增整数),不暴露 source/source_id
|
||||
- `state_id` 由工具层在加载 state 时分配,不区分来源
|
||||
- `source` + `source_id` 为内部字段,仅在写库时使用,不对 LLM 可见
|
||||
|
||||
### 嵌入任务规则
|
||||
- `can_embed=true` 的任务(水课)允许其他任务嵌入到同一时段
|
||||
- 嵌入任务占位时不触发冲突检测(与宿主共存)
|
||||
- `query_available_slots` 返回候选坑位池(先纯空位,必要时补可嵌入位)
|
||||
- `place` 到可嵌入时段时,若已有宿主任务,自动标记 embed_host 关系
|
||||
- 嵌入任务的 locked 继承宿主:宿主不可移动时,嵌入任务也不可单独移动
|
||||
|
||||
### 数据库交互
|
||||
- State 初始化:从 Schedule + ScheduleEvent 加载 existing 任务,从 TaskClassItem 加载 pending 任务;粗排或工具预排成功后,任务转为 suggested
|
||||
- State 落库:Confirm 节点统一处理,将 state 变更转换为 Schedule/ScheduleEvent/TaskClassItem 的增删改
|
||||
- 落库时使用 source + source_id 定位原记录,使用 day_mapping 将 day_index 转回 (week, day_of_week)
|
||||
- 落库时将 (slot_start, slot_end) 展开为逐条 Schedule 记录
|
||||
37
backend/services/agent/tools/active_optimize.go
Normal file
37
backend/services/agent/tools/active_optimize.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package agenttools
|
||||
|
||||
import "strings"
|
||||
|
||||
var activeOptimizeAllowedTools = map[string]struct{}{
|
||||
ToolNameContextToolsAdd: {},
|
||||
ToolNameContextToolsRemove: {},
|
||||
"analyze_health": {},
|
||||
"move": {},
|
||||
"swap": {},
|
||||
}
|
||||
|
||||
// IsToolAllowedInActiveOptimize 判定工具是否允许出现在“粗排后主动优化专用模式”里。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只做场景级白名单裁剪,不参与工具是否已注册、是否被临时禁用、是否需要 confirm 的判断;
|
||||
// 2. 该白名单只服务于“首次粗排后自动微调”链路,避免 LLM 在主动优化时重新暴露大量读工具;
|
||||
// 3. context_tools_add/remove 仍保留,是为了兼容系统级动态区协议,但不代表会重新放开其它业务工具。
|
||||
func IsToolAllowedInActiveOptimize(name string) bool {
|
||||
_, ok := activeOptimizeAllowedTools[strings.TrimSpace(name)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// FilterSchemasForActiveOptimize 过滤出主动优化专用模式允许暴露给 LLM 的工具 schema。
|
||||
func FilterSchemasForActiveOptimize(schemas []ToolSchemaEntry) []ToolSchemaEntry {
|
||||
if len(schemas) == 0 {
|
||||
return nil
|
||||
}
|
||||
filtered := make([]ToolSchemaEntry, 0, len(schemas))
|
||||
for _, item := range schemas {
|
||||
if !IsToolAllowedInActiveOptimize(item.Name) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
407
backend/services/agent/tools/context_tools.go
Normal file
407
backend/services/agent/tools/context_tools.go
Normal file
@@ -0,0 +1,407 @@
|
||||
package agenttools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
toolcontextresult "github.com/LoveLosita/smartflow/backend/services/agent/tools/tool_context_result"
|
||||
)
|
||||
|
||||
type contextToolsAddResult struct {
|
||||
Tool string `json:"tool"`
|
||||
Success bool `json:"success"`
|
||||
Action string `json:"action"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Packs []string `json:"packs,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
}
|
||||
|
||||
type contextToolsRemoveResult struct {
|
||||
Tool string `json:"tool"`
|
||||
Success bool `json:"success"`
|
||||
Action string `json:"action"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Packs []string `json:"packs,omitempty"`
|
||||
All bool `json:"all,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
}
|
||||
|
||||
// NewContextToolsAddHandler 创建 context_tools_add 工具。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只负责校验 domain / packs / mode,并产出结构化结果;
|
||||
// 2. 不直接改 CommonState,真正的激活切流仍由 execute 层读取 observation 后更新快照;
|
||||
// 3. 因为这里拿不到 CommonState,所以卡片展示的是“本次工具结果返回的 domain/packs/mode”,不是全局最终快照。
|
||||
func NewContextToolsAddHandler() ToolHandler {
|
||||
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
|
||||
_ = state
|
||||
|
||||
domain := NormalizeToolDomain(readContextToolString(args["domain"]))
|
||||
if domain == "" {
|
||||
return buildContextToolsAddExecutionResult(args, contextToolsAddResult{
|
||||
Tool: ToolNameContextToolsAdd,
|
||||
Success: false,
|
||||
Action: "reject",
|
||||
Error: "参数非法:domain 仅支持 schedule/taskclass",
|
||||
ErrorCode: "invalid_domain",
|
||||
})
|
||||
}
|
||||
|
||||
mode := strings.ToLower(strings.TrimSpace(readContextToolString(args["mode"])))
|
||||
if mode == "" {
|
||||
mode = "replace"
|
||||
}
|
||||
if mode != "replace" && mode != "merge" {
|
||||
return buildContextToolsAddExecutionResult(args, contextToolsAddResult{
|
||||
Tool: ToolNameContextToolsAdd,
|
||||
Success: false,
|
||||
Action: "reject",
|
||||
Domain: domain,
|
||||
Error: "参数非法:mode 仅支持 replace/merge",
|
||||
ErrorCode: "invalid_mode",
|
||||
})
|
||||
}
|
||||
|
||||
packsRaw := readContextToolStringSlice(args["packs"])
|
||||
packs, errCode, errText := validateContextPacks(domain, packsRaw, false)
|
||||
if errCode != "" {
|
||||
return buildContextToolsAddExecutionResult(args, contextToolsAddResult{
|
||||
Tool: ToolNameContextToolsAdd,
|
||||
Success: false,
|
||||
Action: "reject",
|
||||
Domain: domain,
|
||||
Error: errText,
|
||||
ErrorCode: errCode,
|
||||
})
|
||||
}
|
||||
|
||||
// 1. schedule 未显式传 packs 时,默认激活最小可用包 mutation+analyze。
|
||||
// 2. taskclass 当前没有可选包,所以这里会保持空切片,由 execute 层只保留固定 core。
|
||||
// 3. 这样做可以让 observation 直接表达“本次实际生效的可选包集合”,减少展示层再二次猜测。
|
||||
if domain == ToolDomainSchedule && len(packsRaw) == 0 {
|
||||
packs = ResolveEffectiveToolPacks(domain, nil)
|
||||
}
|
||||
|
||||
return buildContextToolsAddExecutionResult(args, contextToolsAddResult{
|
||||
Tool: ToolNameContextToolsAdd,
|
||||
Success: true,
|
||||
Action: "activate",
|
||||
Domain: domain,
|
||||
Packs: packs,
|
||||
Mode: mode,
|
||||
Message: "已激活目标工具域,可继续调用对应业务工具。",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// NewContextToolsRemoveHandler 创建 context_tools_remove 工具。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只解释 domain / packs / all 的语义,并返回结构化结果;
|
||||
// 2. all=true 表示清空全部业务工具域,domain+packs 表示移除某域下的可选包;
|
||||
// 3. 实际 CommonState 的域/包更新,仍由 execute 层统一消费 observation 完成。
|
||||
func NewContextToolsRemoveHandler() ToolHandler {
|
||||
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
|
||||
_ = state
|
||||
|
||||
all := readContextToolBool(args["all"])
|
||||
domainRaw := strings.ToLower(strings.TrimSpace(readContextToolString(args["domain"])))
|
||||
packsRaw := readContextToolStringSlice(args["packs"])
|
||||
|
||||
// 兼容旧写法:domain=all 也视为清空全部业务工具域。
|
||||
if domainRaw == "all" {
|
||||
all = true
|
||||
}
|
||||
if all {
|
||||
return buildContextToolsRemoveExecutionResult(args, contextToolsRemoveResult{
|
||||
Tool: ToolNameContextToolsRemove,
|
||||
Success: true,
|
||||
Action: "clear_all",
|
||||
All: true,
|
||||
Message: "已移除全部业务工具域,仅保留 context 管理工具。",
|
||||
})
|
||||
}
|
||||
|
||||
domain := NormalizeToolDomain(domainRaw)
|
||||
if domain == "" {
|
||||
return buildContextToolsRemoveExecutionResult(args, contextToolsRemoveResult{
|
||||
Tool: ToolNameContextToolsRemove,
|
||||
Success: false,
|
||||
Action: "reject",
|
||||
Error: "参数非法:需要提供 domain=schedule/taskclass 或 all=true",
|
||||
ErrorCode: "invalid_domain",
|
||||
})
|
||||
}
|
||||
|
||||
packs, errCode, errText := validateContextPacks(domain, packsRaw, true)
|
||||
if errCode != "" {
|
||||
return buildContextToolsRemoveExecutionResult(args, contextToolsRemoveResult{
|
||||
Tool: ToolNameContextToolsRemove,
|
||||
Success: false,
|
||||
Action: "reject",
|
||||
Domain: domain,
|
||||
Error: errText,
|
||||
ErrorCode: errCode,
|
||||
})
|
||||
}
|
||||
|
||||
if len(packs) > 0 {
|
||||
return buildContextToolsRemoveExecutionResult(args, contextToolsRemoveResult{
|
||||
Tool: ToolNameContextToolsRemove,
|
||||
Success: true,
|
||||
Action: "deactivate_packs",
|
||||
Domain: domain,
|
||||
Packs: packs,
|
||||
Message: "已移除指定工具包。",
|
||||
})
|
||||
}
|
||||
|
||||
return buildContextToolsRemoveExecutionResult(args, contextToolsRemoveResult{
|
||||
Tool: ToolNameContextToolsRemove,
|
||||
Success: true,
|
||||
Action: "deactivate",
|
||||
Domain: domain,
|
||||
Message: "已移除指定工具域。",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func buildContextToolsAddExecutionResult(args map[string]any, payload contextToolsAddResult) ToolExecutionResult {
|
||||
observation := marshalContextToolsAddResult(payload)
|
||||
legacy := LegacyResult(ToolNameContextToolsAdd, args, observation)
|
||||
view := toolcontextresult.BuildAddView(toContextToolsAddPayload(payload), observation)
|
||||
return buildContextToolExecutionResult(legacy, args, view)
|
||||
}
|
||||
|
||||
func buildContextToolsRemoveExecutionResult(args map[string]any, payload contextToolsRemoveResult) ToolExecutionResult {
|
||||
observation := marshalContextToolsRemoveResult(payload)
|
||||
legacy := LegacyResult(ToolNameContextToolsRemove, args, observation)
|
||||
view := toolcontextresult.BuildRemoveView(toContextToolsRemovePayload(payload), observation)
|
||||
return buildContextToolExecutionResult(legacy, args, view)
|
||||
}
|
||||
|
||||
// buildContextToolExecutionResult 负责把子包纯展示结构包回 ToolExecutionResult。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做 ContextResultView -> ToolDisplayView 的协议桥接;
|
||||
// 2. 不改写 ObservationText,确保模型侧仍消费原始 observation JSON;
|
||||
// 3. 错误码与错误文案继续复用父包现有 JSON/text 解析逻辑,避免多套失败判定分叉。
|
||||
func buildContextToolExecutionResult(
|
||||
legacy ToolExecutionResult,
|
||||
args map[string]any,
|
||||
view toolcontextresult.ContextResultView,
|
||||
) ToolExecutionResult {
|
||||
result := legacy
|
||||
status := normalizeToolStatus(result.Status)
|
||||
if status == "" {
|
||||
status = ToolStatusDone
|
||||
}
|
||||
|
||||
collapsed := cloneAnyMap(view.Collapsed)
|
||||
if collapsed == nil {
|
||||
collapsed = make(map[string]any)
|
||||
}
|
||||
expanded := cloneAnyMap(view.Expanded)
|
||||
if expanded == nil {
|
||||
expanded = make(map[string]any)
|
||||
}
|
||||
|
||||
collapsed["status"] = status
|
||||
if _, exists := collapsed["status_label"]; !exists {
|
||||
collapsed["status_label"] = resolveToolStatusLabelCN(status)
|
||||
}
|
||||
if _, exists := expanded["raw_text"]; !exists {
|
||||
expanded["raw_text"] = result.ObservationText
|
||||
}
|
||||
|
||||
result.Status = status
|
||||
result.Success = status == ToolStatusDone
|
||||
result.ResultView = &ToolDisplayView{
|
||||
ViewType: strings.TrimSpace(view.ViewType),
|
||||
Version: view.Version,
|
||||
Collapsed: collapsed,
|
||||
Expanded: expanded,
|
||||
}
|
||||
if title, ok := readStringAnyMap(collapsed, "title"); ok {
|
||||
result.Summary = title
|
||||
}
|
||||
if !result.Success {
|
||||
errorCode, errorMessage := extractToolErrorInfo(result.ObservationText, status)
|
||||
if strings.TrimSpace(result.ErrorCode) == "" {
|
||||
result.ErrorCode = strings.TrimSpace(errorCode)
|
||||
}
|
||||
if strings.TrimSpace(result.ErrorMessage) == "" {
|
||||
result.ErrorMessage = strings.TrimSpace(errorMessage)
|
||||
}
|
||||
}
|
||||
return EnsureToolResultDefaults(result, args)
|
||||
}
|
||||
|
||||
func toContextToolsAddPayload(payload contextToolsAddResult) toolcontextresult.ContextToolsAddPayload {
|
||||
return toolcontextresult.ContextToolsAddPayload{
|
||||
Tool: payload.Tool,
|
||||
Success: payload.Success,
|
||||
Action: payload.Action,
|
||||
Domain: payload.Domain,
|
||||
Packs: append([]string(nil), payload.Packs...),
|
||||
Mode: payload.Mode,
|
||||
Message: payload.Message,
|
||||
Error: payload.Error,
|
||||
ErrorCode: payload.ErrorCode,
|
||||
}
|
||||
}
|
||||
|
||||
func toContextToolsRemovePayload(payload contextToolsRemoveResult) toolcontextresult.ContextToolsRemovePayload {
|
||||
return toolcontextresult.ContextToolsRemovePayload{
|
||||
Tool: payload.Tool,
|
||||
Success: payload.Success,
|
||||
Action: payload.Action,
|
||||
Domain: payload.Domain,
|
||||
Packs: append([]string(nil), payload.Packs...),
|
||||
All: payload.All,
|
||||
Message: payload.Message,
|
||||
Error: payload.Error,
|
||||
ErrorCode: payload.ErrorCode,
|
||||
}
|
||||
}
|
||||
|
||||
func validateContextPacks(domain string, packs []string, forRemove bool) ([]string, string, string) {
|
||||
normalizedDomain := NormalizeToolDomain(domain)
|
||||
if normalizedDomain == "" {
|
||||
return nil, "invalid_domain", "参数非法:domain 非法"
|
||||
}
|
||||
if len(packs) == 0 {
|
||||
return nil, "", ""
|
||||
}
|
||||
|
||||
if normalizedDomain == ToolDomainTaskClass {
|
||||
return nil, "unsupported_packs_for_domain", "参数非法:taskclass 暂不支持 packs"
|
||||
}
|
||||
|
||||
normalized := make([]string, 0, len(packs))
|
||||
seen := make(map[string]struct{}, len(packs))
|
||||
for _, raw := range packs {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
pack := NormalizeToolPack(normalizedDomain, trimmed)
|
||||
if pack == "" {
|
||||
return nil, "invalid_pack", "参数非法:存在不支持的 pack"
|
||||
}
|
||||
if IsFixedToolPack(normalizedDomain, pack) {
|
||||
if forRemove {
|
||||
return nil, "fixed_pack_forbidden", "参数非法:core 为固定包,不允许 remove"
|
||||
}
|
||||
return nil, "fixed_pack_forbidden", "参数非法:core 为固定包,不允许 add"
|
||||
}
|
||||
if _, exists := seen[pack]; exists {
|
||||
continue
|
||||
}
|
||||
seen[pack] = struct{}{}
|
||||
normalized = append(normalized, pack)
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return nil, "invalid_pack", "参数非法:packs 为空或无效"
|
||||
}
|
||||
return normalized, "", ""
|
||||
}
|
||||
|
||||
func readContextToolString(raw any) string {
|
||||
text, _ := raw.(string)
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func readContextToolStringSlice(raw any) []string {
|
||||
switch typed := raw.(type) {
|
||||
case []string:
|
||||
out := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
text := strings.TrimSpace(item)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, text)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
text, ok := item.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, text)
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
text := strings.TrimSpace(typed)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(text, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func readContextToolBool(raw any) bool {
|
||||
switch v := raw.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
value := strings.ToLower(strings.TrimSpace(v))
|
||||
return value == "1" || value == "true" || value == "yes"
|
||||
case float64:
|
||||
return v != 0
|
||||
case float32:
|
||||
return v != 0
|
||||
case int:
|
||||
return v != 0
|
||||
case int8:
|
||||
return v != 0
|
||||
case int16:
|
||||
return v != 0
|
||||
case int32:
|
||||
return v != 0
|
||||
case int64:
|
||||
return v != 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func marshalContextToolsAddResult(result contextToolsAddResult) string {
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return `{"tool":"context_tools_add","success":false,"action":"reject","error":"result encode failed","error_code":"encode_failed"}`
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func marshalContextToolsRemoveResult(result contextToolsRemoveResult) string {
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return `{"tool":"context_tools_remove","success":false,"action":"reject","error":"result encode failed","error_code":"encode_failed"}`
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
1114
backend/services/agent/tools/execution_result.go
Normal file
1114
backend/services/agent/tools/execution_result.go
Normal file
File diff suppressed because it is too large
Load Diff
431
backend/services/agent/tools/registry.go
Normal file
431
backend/services/agent/tools/registry.go
Normal file
@@ -0,0 +1,431 @@
|
||||
package agenttools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
"github.com/LoveLosita/smartflow/backend/services/agent/tools/web"
|
||||
ragservice "github.com/LoveLosita/smartflow/backend/services/rag"
|
||||
)
|
||||
|
||||
// ToolHandler 约定所有工具的统一执行签名。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责消费当前 ScheduleState 与模型传入参数;
|
||||
// 2. 返回 ToolExecutionResult,供 execute 节点写回 observation 与结构化事件;
|
||||
// 3. 不负责 confirm、上下文注入、轮次控制,这些由上层节点处理。
|
||||
type ToolHandler func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult
|
||||
|
||||
// ToolSchemaEntry 描述注入给模型的工具快照。
|
||||
type ToolSchemaEntry struct {
|
||||
Name string
|
||||
Desc string
|
||||
SchemaText string
|
||||
}
|
||||
|
||||
// DefaultRegistryDeps 描述默认注册表需要的外部依赖。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只承载工具层需要的依赖注入,不承载业务状态;
|
||||
// 2. 某些依赖即便暂未使用也允许保留,避免业务层重新到处 new;
|
||||
// 3. 具体依赖缺失时由对应工具自行返回结构化失败结果。
|
||||
type DefaultRegistryDeps struct {
|
||||
RAGRuntime ragservice.Runtime
|
||||
|
||||
// WebSearchProvider 为 nil 时,web_search / web_fetch 仍会注册,
|
||||
// 但 handler 会返回“暂未启用”的只读 observation,不阻断主流程。
|
||||
WebSearchProvider web.SearchProvider
|
||||
|
||||
// TaskClassWriteDeps 供 upsert_task_class 调用持久化层。
|
||||
TaskClassWriteDeps TaskClassWriteDeps
|
||||
}
|
||||
|
||||
// ToolRegistry 管理工具注册、过滤与执行。
|
||||
type ToolRegistry struct {
|
||||
handlers map[string]ToolHandler
|
||||
schemas []ToolSchemaEntry
|
||||
deps DefaultRegistryDeps
|
||||
}
|
||||
|
||||
// temporaryDisabledTools 描述“已注册但当前阶段临时禁用”的工具。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 这些工具仍保留定义,避免 prompt / 旧链路 / 历史日志里出现悬空名字;
|
||||
// 2. execute 会在调用前统一阻断,并向模型返回纠错提示;
|
||||
// 3. ToolNames / Schemas 也会默认隐藏它们,避免继续污染 msg0。
|
||||
var temporaryDisabledTools = map[string]bool{}
|
||||
|
||||
// IsTemporarilyDisabledTool 判断工具是否在当前阶段被临时禁用。
|
||||
func IsTemporarilyDisabledTool(name string) bool {
|
||||
return temporaryDisabledTools[strings.TrimSpace(name)]
|
||||
}
|
||||
|
||||
// NewToolRegistry 创建空注册表。
|
||||
func NewToolRegistry() *ToolRegistry {
|
||||
return NewToolRegistryWithDeps(DefaultRegistryDeps{})
|
||||
}
|
||||
|
||||
// NewToolRegistryWithDeps 创建带依赖的空注册表。
|
||||
func NewToolRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
|
||||
return &ToolRegistry{
|
||||
handlers: make(map[string]ToolHandler),
|
||||
schemas: make([]ToolSchemaEntry, 0),
|
||||
deps: deps,
|
||||
}
|
||||
}
|
||||
|
||||
// Register 注册一个工具及其 schema。
|
||||
func (r *ToolRegistry) Register(name, desc, schemaText string, handler ToolHandler) {
|
||||
r.handlers[name] = handler
|
||||
r.schemas = append(r.schemas, ToolSchemaEntry{
|
||||
Name: name,
|
||||
Desc: desc,
|
||||
SchemaText: schemaText,
|
||||
})
|
||||
}
|
||||
|
||||
// Execute 执行指定工具。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只负责找到 handler 并调用;
|
||||
// 2. 工具临时禁用时直接返回 blocked 结构化结果,不进入 handler;
|
||||
// 3. 参数 schema 级纠错仍由 handler 内处理。
|
||||
func (r *ToolRegistry) Execute(state *schedule.ScheduleState, toolName string, args map[string]any) ToolExecutionResult {
|
||||
if r.IsToolTemporarilyDisabled(toolName) {
|
||||
observation := fmt.Sprintf("工具 %q 当前阶段已临时禁用,请优先使用 analyze_health、move、swap 等当前主链工具。", strings.TrimSpace(toolName))
|
||||
return BlockedResult(toolName, args, observation, "tool_temporarily_disabled", observation)
|
||||
}
|
||||
handler, ok := r.handlers[toolName]
|
||||
if !ok {
|
||||
observation := fmt.Sprintf("工具调用失败:未知工具 %q。可用工具:%s", toolName, strings.Join(r.ToolNames(), "、"))
|
||||
result := LegacyResult(toolName, args, observation)
|
||||
result.Status = ToolStatusFailed
|
||||
result.Success = false
|
||||
result.ErrorCode = "unknown_tool"
|
||||
result.ErrorMessage = observation
|
||||
return EnsureToolResultDefaults(result, args)
|
||||
}
|
||||
return EnsureToolResultDefaults(handler(state, args), args)
|
||||
}
|
||||
|
||||
// HasTool 判断工具是否已注册且当前可见。
|
||||
func (r *ToolRegistry) HasTool(name string) bool {
|
||||
if r.IsToolTemporarilyDisabled(name) {
|
||||
return false
|
||||
}
|
||||
_, ok := r.handlers[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsToolTemporarilyDisabled 判断工具是否处于“已注册但暂不允许调用”状态。
|
||||
func (r *ToolRegistry) IsToolTemporarilyDisabled(name string) bool {
|
||||
return IsTemporarilyDisabledTool(name)
|
||||
}
|
||||
|
||||
// ToolNames 返回当前可暴露给模型的工具名。
|
||||
func (r *ToolRegistry) ToolNames() []string {
|
||||
names := make([]string, 0, len(r.schemas))
|
||||
for _, item := range r.schemas {
|
||||
if r.IsToolTemporarilyDisabled(item.Name) {
|
||||
continue
|
||||
}
|
||||
names = append(names, item.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// Schemas 返回当前可暴露给模型的 schema 快照。
|
||||
func (r *ToolRegistry) Schemas() []ToolSchemaEntry {
|
||||
result := make([]ToolSchemaEntry, 0, len(r.schemas))
|
||||
for _, item := range r.schemas {
|
||||
if r.IsToolTemporarilyDisabled(item.Name) {
|
||||
continue
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// SchemasForActiveDomain 返回某业务域当前真正可见的工具 schema。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. context_tools_add/remove 始终保留,用于动态区协议;
|
||||
// 2. 仅当工具域已激活时,才暴露该域下可见工具;
|
||||
// 3. schedule 域支持按 pack 过滤;taskclass 目前只有 core。
|
||||
func (r *ToolRegistry) SchemasForActiveDomain(activeDomain string, activePacks []string) []ToolSchemaEntry {
|
||||
normalizedDomain := NormalizeToolDomain(activeDomain)
|
||||
effectivePacks := ResolveEffectiveToolPacks(normalizedDomain, activePacks)
|
||||
effectivePackSet := make(map[string]struct{}, len(effectivePacks))
|
||||
for _, pack := range effectivePacks {
|
||||
effectivePackSet[pack] = struct{}{}
|
||||
}
|
||||
|
||||
selected := make([]ToolSchemaEntry, 0, len(r.schemas))
|
||||
for _, item := range r.schemas {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
if r.IsToolTemporarilyDisabled(name) {
|
||||
continue
|
||||
}
|
||||
if IsContextManagementTool(name) {
|
||||
selected = append(selected, item)
|
||||
continue
|
||||
}
|
||||
if normalizedDomain == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
domain, pack, ok := ResolveToolDomainPack(name)
|
||||
if !ok {
|
||||
// 兼容历史未建档工具:仅在 schedule 域下继续暴露,避免突然失联。
|
||||
if normalizedDomain == ToolDomainSchedule {
|
||||
selected = append(selected, item)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if domain != normalizedDomain {
|
||||
continue
|
||||
}
|
||||
if IsFixedToolPack(domain, pack) {
|
||||
selected = append(selected, item)
|
||||
continue
|
||||
}
|
||||
if _, exists := effectivePackSet[pack]; exists {
|
||||
selected = append(selected, item)
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]ToolSchemaEntry, len(selected))
|
||||
copy(result, selected)
|
||||
return result
|
||||
}
|
||||
|
||||
// IsToolVisibleInDomain 判断某工具在当前动态区下是否应对模型可见。
|
||||
func (r *ToolRegistry) IsToolVisibleInDomain(activeDomain string, activePacks []string, toolName string) bool {
|
||||
name := strings.TrimSpace(toolName)
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for _, item := range r.SchemasForActiveDomain(activeDomain, activePacks) {
|
||||
if strings.TrimSpace(item.Name) == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsWriteTool 判断工具是否属于写工具。
|
||||
func (r *ToolRegistry) IsWriteTool(name string) bool {
|
||||
return writeTools[strings.TrimSpace(name)]
|
||||
}
|
||||
|
||||
// IsScheduleMutationTool 判断工具是否会真实修改 ScheduleState 中的日程布局。
|
||||
// upsert_task_class 会写库,但不修改当前日程预览,因此不计入此集合。
|
||||
func (r *ToolRegistry) IsScheduleMutationTool(name string) bool {
|
||||
return scheduleMutationTools[strings.TrimSpace(name)]
|
||||
}
|
||||
|
||||
// RequiresScheduleState 判断工具是否依赖 ScheduleState。
|
||||
func (r *ToolRegistry) RequiresScheduleState(name string) bool {
|
||||
return !scheduleFreeTools[strings.TrimSpace(name)]
|
||||
}
|
||||
|
||||
var writeTools = map[string]bool{
|
||||
"place": true,
|
||||
"move": true,
|
||||
"swap": true,
|
||||
"batch_move": true,
|
||||
"queue_apply_head_move": true,
|
||||
"unplace": true,
|
||||
"upsert_task_class": true,
|
||||
}
|
||||
|
||||
var scheduleMutationTools = map[string]bool{
|
||||
"place": true,
|
||||
"move": true,
|
||||
"swap": true,
|
||||
"batch_move": true,
|
||||
"queue_apply_head_move": true,
|
||||
"unplace": true,
|
||||
}
|
||||
|
||||
// scheduleFreeTools 描述“即使没有 ScheduleState 也能安全执行”的工具。
|
||||
var scheduleFreeTools = map[string]bool{
|
||||
"web_search": true,
|
||||
"web_fetch": true,
|
||||
"upsert_task_class": true,
|
||||
ToolNameContextToolsAdd: true,
|
||||
ToolNameContextToolsRemove: true,
|
||||
}
|
||||
|
||||
// NewDefaultRegistry 创建默认注册表。
|
||||
func NewDefaultRegistry() *ToolRegistry {
|
||||
return NewDefaultRegistryWithDeps(DefaultRegistryDeps{})
|
||||
}
|
||||
|
||||
// NewDefaultRegistryWithDeps 创建带依赖的默认注册表。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先注册上下文管理工具,保证动态区协议随时可用;
|
||||
// 2. 再注册 schedule 域的读、诊断、写工具;
|
||||
// 3. 最后注册 taskclass 与 web 工具,并统一按 name 排序,保证 prompt 输出稳定。
|
||||
func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
|
||||
r := NewToolRegistryWithDeps(deps)
|
||||
|
||||
registerContextTools(r)
|
||||
registerScheduleReadTools(r)
|
||||
registerScheduleAnalyzeTools(r)
|
||||
registerScheduleMutationTools(r)
|
||||
registerTaskClassTools(r, deps)
|
||||
registerWebTools(r, deps)
|
||||
|
||||
sort.Slice(r.schemas, func(i, j int) bool {
|
||||
return r.schemas[i].Name < r.schemas[j].Name
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func registerContextTools(r *ToolRegistry) {
|
||||
r.Register(
|
||||
ToolNameContextToolsAdd,
|
||||
"激活指定工具域,并可附带 schedule 二级包 packs。core 固定注入。",
|
||||
`{"name":"context_tools_add","parameters":{"domain":{"type":"string","required":true,"enum":["schedule","taskclass"]},"packs":{"type":"array","items":{"type":"string","enum":["mutation","analyze","detail_read","deep_analyze","queue","web"]}},"mode":{"type":"string","enum":["replace","merge"]}}}`,
|
||||
NewContextToolsAddHandler(),
|
||||
)
|
||||
r.Register(
|
||||
ToolNameContextToolsRemove,
|
||||
"移除指定工具域、指定二级包,或清空全部业务工具域(all=true)。core 固定包不支持 remove。",
|
||||
`{"name":"context_tools_remove","parameters":{"domain":{"type":"string","enum":["schedule","taskclass","all"]},"packs":{"type":"array","items":{"type":"string","enum":["mutation","analyze","detail_read","deep_analyze","queue","web"]}},"all":{"type":"bool"}}}`,
|
||||
NewContextToolsRemoveHandler(),
|
||||
)
|
||||
}
|
||||
|
||||
func registerScheduleReadTools(r *ToolRegistry) {
|
||||
r.Register(
|
||||
"get_overview",
|
||||
"获取当前窗口总览:保留课程占位统计,展开任务清单。",
|
||||
`{"name":"get_overview","parameters":{}}`,
|
||||
NewGetOverviewToolHandler(),
|
||||
)
|
||||
r.Register(
|
||||
"query_range",
|
||||
"查看某天或某时段的占用详情。day 必填,slot_start/slot_end 选填。",
|
||||
`{"name":"query_range","parameters":{"day":{"type":"int","required":true},"slot_start":{"type":"int"},"slot_end":{"type":"int"}}}`,
|
||||
NewQueryRangeToolHandler(),
|
||||
)
|
||||
r.Register(
|
||||
"query_available_slots",
|
||||
"查询候选空位池,适合 move 前筛落点。",
|
||||
`{"name":"query_available_slots","parameters":{"span":{"type":"int"},"duration":{"type":"int"},"limit":{"type":"int"},"allow_embed":{"type":"bool"},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"slot_type":{"type":"string"},"slot_types":{"type":"array","items":{"type":"string"}},"exclude_sections":{"type":"array","items":{"type":"int"}},"after_section":{"type":"int"},"before_section":{"type":"int"},"section_from":{"type":"int"},"section_to":{"type":"int"}}}`,
|
||||
NewQueryAvailableSlotsToolHandler(),
|
||||
)
|
||||
r.Register(
|
||||
"query_target_tasks",
|
||||
"查询候选任务集合,可按 status/week/day/task_id/category 筛选;支持 enqueue。",
|
||||
`{"name":"query_target_tasks","parameters":{"status":{"type":"string","enum":["all","existing","suggested","pending"]},"category":{"type":"string"},"limit":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"task_ids":{"type":"array","items":{"type":"int"}},"task_id":{"type":"int"},"task_item_ids":{"type":"array","items":{"type":"int"}},"task_item_id":{"type":"int"},"enqueue":{"type":"bool"},"reset_queue":{"type":"bool"}}}`,
|
||||
NewQueryTargetTasksToolHandler(),
|
||||
)
|
||||
r.Register(
|
||||
"queue_pop_head",
|
||||
"弹出并返回当前队首任务;若已有 current 则复用。",
|
||||
`{"name":"queue_pop_head","parameters":{}}`,
|
||||
NewQueuePopHeadToolHandler(),
|
||||
)
|
||||
r.Register(
|
||||
"queue_status",
|
||||
"查看当前队列状态(pending/current/completed/skipped)。",
|
||||
`{"name":"queue_status","parameters":{}}`,
|
||||
NewQueueStatusToolHandler(),
|
||||
)
|
||||
r.Register(
|
||||
"get_task_info",
|
||||
"查看单个任务详情,包括类别、状态与落位。",
|
||||
`{"name":"get_task_info","parameters":{"task_id":{"type":"int","required":true}}}`,
|
||||
NewGetTaskInfoToolHandler(),
|
||||
)
|
||||
}
|
||||
|
||||
func registerScheduleAnalyzeTools(r *ToolRegistry) {
|
||||
r.Register(
|
||||
"analyze_rhythm",
|
||||
"分析学习节奏与切换情况。",
|
||||
`{"name":"analyze_rhythm","parameters":{"category":{"type":"string"},"include_pending":{"type":"bool"},"detail":{"type":"string","enum":["summary","full"]},"hard_categories":{"type":"array","items":{"type":"string"}}}}`,
|
||||
NewAnalyzeRhythmToolHandler(),
|
||||
)
|
||||
r.Register(
|
||||
"analyze_health",
|
||||
"主动优化裁判入口:聚焦 rhythm/semantic_profile/tightness,判断当前是否还值得继续优化,并给出候选。",
|
||||
`{"name":"analyze_health","parameters":{"detail":{"type":"string","enum":["summary","full"]},"dimensions":{"type":"array","items":{"type":"string"}},"threshold":{"type":"string","enum":["strict","normal","relaxed"]}}}`,
|
||||
NewAnalyzeHealthToolHandler(),
|
||||
)
|
||||
}
|
||||
|
||||
func registerScheduleMutationTools(r *ToolRegistry) {
|
||||
r.Register(
|
||||
"place",
|
||||
"将一个待安排任务预排到指定位置。task_id/day/slot_start 必填。",
|
||||
`{"name":"place","parameters":{"task_id":{"type":"int","required":true},"day":{"type":"int","required":true},"slot_start":{"type":"int","required":true}}}`,
|
||||
NewPlaceToolHandler(),
|
||||
)
|
||||
r.Register(
|
||||
"move",
|
||||
"将一个已预排任务(仅 suggested)移动到新位置。task_id/new_day/new_slot_start 必填。",
|
||||
`{"name":"move","parameters":{"task_id":{"type":"int","required":true},"new_day":{"type":"int","required":true},"new_slot_start":{"type":"int","required":true}}}`,
|
||||
NewMoveToolHandler(),
|
||||
)
|
||||
r.Register(
|
||||
"swap",
|
||||
"交换两个已落位任务的位置。task_a/task_b 必填,且两任务时长必须一致。",
|
||||
`{"name":"swap","parameters":{"task_a":{"type":"int","required":true},"task_b":{"type":"int","required":true}}}`,
|
||||
NewSwapToolHandler(),
|
||||
)
|
||||
r.Register(
|
||||
"batch_move",
|
||||
"原子性批量移动多个任务。moves 必填。",
|
||||
`{"name":"batch_move","parameters":{"moves":{"type":"array","required":true,"items":{"task_id":"int","new_day":"int","new_slot_start":"int"}}}}`,
|
||||
NewBatchMoveToolHandler(),
|
||||
)
|
||||
r.Register(
|
||||
"queue_apply_head_move",
|
||||
"将当前队首任务移动到指定位置并自动出队。new_day/new_slot_start 必填。",
|
||||
`{"name":"queue_apply_head_move","parameters":{"new_day":{"type":"int","required":true},"new_slot_start":{"type":"int","required":true}}}`,
|
||||
NewQueueApplyHeadMoveToolHandler(),
|
||||
)
|
||||
r.Register(
|
||||
"queue_skip_head",
|
||||
"跳过当前队首任务,将其标记为 skipped。",
|
||||
`{"name":"queue_skip_head","parameters":{"reason":{"type":"string"}}}`,
|
||||
NewQueueSkipHeadToolHandler(),
|
||||
)
|
||||
r.Register(
|
||||
"unplace",
|
||||
"将一个已落位任务移除,恢复为待安排状态。task_id 必填。",
|
||||
`{"name":"unplace","parameters":{"task_id":{"type":"int","required":true}}}`,
|
||||
NewUnplaceToolHandler(),
|
||||
)
|
||||
}
|
||||
|
||||
func registerTaskClassTools(r *ToolRegistry, deps DefaultRegistryDeps) {
|
||||
r.Register(
|
||||
"upsert_task_class",
|
||||
"创建或更新任务类(统一写入口,必须 confirm)。auto 模式下 start_date/end_date 必须在 task_class 顶层字段。",
|
||||
`{"name":"upsert_task_class","parameters":{"id":{"type":"int"},"task_class":{"type":"object","required":true},"items":{"type":"array","items":{"type":"object"}},"source":{"type":"string"}}}`,
|
||||
NewTaskClassUpsertToolHandler(deps.TaskClassWriteDeps),
|
||||
)
|
||||
}
|
||||
|
||||
func registerWebTools(r *ToolRegistry, deps DefaultRegistryDeps) {
|
||||
r.Register(
|
||||
"web_search",
|
||||
"Web 搜索:根据 query 返回结构化检索结果。query 必填。",
|
||||
`{"name":"web_search","parameters":{"query":{"type":"string","required":true},"top_k":{"type":"int"},"domain_allow":{"type":"array","items":{"type":"string"}},"recency_days":{"type":"int"}}}`,
|
||||
NewWebSearchToolHandler(deps.WebSearchProvider),
|
||||
)
|
||||
r.Register(
|
||||
"web_fetch",
|
||||
"抓取指定 URL 的正文内容并做最小清洗。url 必填。",
|
||||
`{"name":"web_fetch","parameters":{"url":{"type":"string","required":true},"max_chars":{"type":"int"}}}`,
|
||||
NewWebFetchToolHandler(web.NewFetcher()),
|
||||
)
|
||||
}
|
||||
1155
backend/services/agent/tools/schedule/analyze_health_candidates.go
Normal file
1155
backend/services/agent/tools/schedule/analyze_health_candidates.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
package schedule
|
||||
|
||||
import "strings"
|
||||
|
||||
// buildAnalyzeHealthDecisionV2 生成 analyze_health 在主动优化场景下的最终裁决。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 先尊重 base 层的判断:只有 base 明确允许继续优化时,才进入候选枚举。
|
||||
// 2. 候选只来自后端已经验证合法、并且复诊后确实变好的 move/swap 方案。
|
||||
// 3. 若没有真正改善的候选,则明确返回 close,避免把 LLM 推回开放式全窗搜索。
|
||||
func buildAnalyzeHealthDecisionV2(
|
||||
state *ScheduleState,
|
||||
snapshot analyzeHealthSnapshot,
|
||||
) analyzeHealthDecision {
|
||||
base := buildAnalyzeHealthDecisionBase(state, snapshot)
|
||||
decision := analyzeHealthDecision{
|
||||
ShouldContinueOptimize: base.ShouldContinueOptimize,
|
||||
PrimaryProblem: base.PrimaryProblem,
|
||||
ProblemScope: base.ProblemScope,
|
||||
IsForcedImperfection: base.IsForcedImperfection,
|
||||
RecommendedOperation: base.RecommendedOperation,
|
||||
ImprovementSignal: buildHealthImprovementSignal(
|
||||
snapshot.Rhythm,
|
||||
snapshot.Tightness,
|
||||
base.ProblemScope,
|
||||
base.RecommendedOperation,
|
||||
snapshot.Profile,
|
||||
snapshot.Feasibility,
|
||||
),
|
||||
}
|
||||
|
||||
if !shouldEnterHealthCandidateLoop(base) {
|
||||
decision.Candidates = []analyzeHealthCandidate{
|
||||
buildHealthCloseCandidate("保持当前安排并收口:当前不需要再进入主动优化候选。", snapshot, base),
|
||||
}
|
||||
decision.ShouldContinueOptimize = false
|
||||
return decision
|
||||
}
|
||||
|
||||
bestScan, ok := findBestHealthProblemScanResult(state, snapshot)
|
||||
if !ok || bestScan.Problem.Kind != healthProblemHeavyAdjacent || bestScan.Problem.Pair == nil {
|
||||
decision.Candidates = []analyzeHealthCandidate{
|
||||
buildHealthCloseCandidate("保持当前安排并收口:当前没有值得继续处理的局部认知问题。", snapshot, base),
|
||||
}
|
||||
decision.ShouldContinueOptimize = false
|
||||
decision.PrimaryProblem = "当前没有发现值得继续处理的局部认知问题"
|
||||
decision.ProblemScope = nil
|
||||
decision.RecommendedOperation = "close"
|
||||
if snapshot.Tightness.TightnessLevel == "locked" || snapshot.Tightness.TightnessLevel == "tight" {
|
||||
decision.IsForcedImperfection = true
|
||||
}
|
||||
decision.ImprovementSignal = buildHealthImprovementSignal(
|
||||
snapshot.Rhythm,
|
||||
snapshot.Tightness,
|
||||
decision.ProblemScope,
|
||||
decision.RecommendedOperation,
|
||||
snapshot.Profile,
|
||||
snapshot.Feasibility,
|
||||
)
|
||||
return decision
|
||||
}
|
||||
|
||||
decision.PrimaryProblem = bestScan.Problem.Summary
|
||||
decision.ProblemScope = bestScan.Problem.Scope
|
||||
decision.Candidates = append(decision.Candidates, bestScan.Candidates...)
|
||||
decision.Candidates = append(decision.Candidates,
|
||||
buildHealthCloseCandidate("如果不想继续挪动,也可以保持当前安排并直接收口。", snapshot, base),
|
||||
)
|
||||
decision.ShouldContinueOptimize = true
|
||||
decision.RecommendedOperation = strings.TrimSpace(bestScan.Candidates[0].Tool)
|
||||
decision.ImprovementSignal = buildHealthImprovementSignal(
|
||||
snapshot.Rhythm,
|
||||
snapshot.Tightness,
|
||||
decision.ProblemScope,
|
||||
decision.RecommendedOperation,
|
||||
snapshot.Profile,
|
||||
snapshot.Feasibility,
|
||||
)
|
||||
return decision
|
||||
}
|
||||
|
||||
// findBestHealthProblemScanResult 每轮重扫所有 heavy_adjacent 天,并选出当前收益最高的一天。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先收集所有仍需关注的 heavy_adjacent 天;这里只扫描问题天,不改候选类型。
|
||||
// 2. 再对每一天复用现有单天候选试算逻辑,保持“合法且复诊后确实变好”这一过滤语义不变。
|
||||
// 3. 最后只返回收益最高且达到最小阈值的一天;最终 decision.candidates 仍只来自这一天天然候选集。
|
||||
func findBestHealthProblemScanResult(
|
||||
state *ScheduleState,
|
||||
snapshot analyzeHealthSnapshot,
|
||||
) (analyzeHealthProblemScanResult, bool) {
|
||||
problems := collectRepairableHeavyAdjacentProblems(state, snapshot)
|
||||
if len(problems) == 0 {
|
||||
return analyzeHealthProblemScanResult{}, false
|
||||
}
|
||||
|
||||
results := make([]analyzeHealthProblemScanResult, 0, len(problems))
|
||||
for _, problem := range problems {
|
||||
scan, ok := buildHealthProblemScanResult(state, snapshot, problem)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
results = append(results, scan)
|
||||
}
|
||||
return selectBestHealthProblemScanResult(results)
|
||||
}
|
||||
|
||||
// shouldEnterHealthCandidateLoop 判断本轮是否应进入“候选式主动优化”。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 只有 base 已判定“值得继续优化”时才放行。
|
||||
// 2. 当前主动优化闭环只接受 move / swap 两类操作,其它动作不进入候选生成。
|
||||
// 3. 这样可以挡住 “ask_user / close / forced imperfection” 被后续枚举误覆盖的问题。
|
||||
func shouldEnterHealthCandidateLoop(base analyzeHealthDecisionBase) bool {
|
||||
if !base.ShouldContinueOptimize {
|
||||
return false
|
||||
}
|
||||
switch strings.TrimSpace(base.RecommendedOperation) {
|
||||
case "move", "swap":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user