Version: 0.9.62.dev.260502
后端: 1. 主动调度补齐 `unfinished_feedback` 定位闭环——用户补充信息先在滚动窗口内定位到可校验的日程块,定位失败则继续 ask_user,不再硬猜 target_id 或直接跑 graph。 2. 聊天占管重跑链路加并发保护——`waiting_user_reply -> rerunning` 改为 DB CAS 抢占,重复补充只返回可见等待提示,避免并发生成多份 preview。 3. rerun 结果回写继续收口——新 preview_id 同步回 trigger 审计指针,session 只在拿到新 preview 时更新当前预览,ready_preview 后清空追问状态并释放回普通聊天。 4. 主动调度事件校验放宽 unfinished_feedback 的空 target 场景,允许先触发、后定位,再进入 graph + preview 主链路。
This commit is contained in:
84
backend/active_scheduler/feedbacklocate/dto.go
Normal file
84
backend/active_scheduler/feedbacklocate/dto.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package feedbacklocate
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
// ActionSelectCandidate 表示模型已经把补充信息定位到某个 schedule_event。
|
||||
ActionSelectCandidate = "select_candidate"
|
||||
// ActionAskUser 表示模型无法稳定定位,需要继续追问用户。
|
||||
ActionAskUser = "ask_user"
|
||||
|
||||
// TargetTypeScheduleEvent 是本阶段允许返回的唯一目标类型。
|
||||
TargetTypeScheduleEvent = "schedule_event"
|
||||
)
|
||||
|
||||
// Request 是反馈定位节点的最小输入。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只承载定位当前补充信息所需的上下文,不携带正式排程写入能力。
|
||||
// 2. 不负责候选筛选或 preview 落库,最终只返回“定位成功”或“继续追问”。
|
||||
type Request struct {
|
||||
UserID int
|
||||
UserMessage string
|
||||
PendingQuestion string
|
||||
MissingInfo []string
|
||||
}
|
||||
|
||||
// Result 是反馈定位节点的最小输出。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只表达“是否已经定位到 schedule_event”以及“是否需要继续 ask_user”。
|
||||
// 2. 不携带正式日程写入结果,也不直接产出 preview。
|
||||
type Result struct {
|
||||
Action string
|
||||
TargetType string
|
||||
TargetID int
|
||||
Reason string
|
||||
AskUserQuestion string
|
||||
}
|
||||
|
||||
// IsResolved 表示本次定位是否已经拿到可校验的 schedule_event。
|
||||
//
|
||||
// 输入输出语义:
|
||||
// 1. 只有 action=select_candidate 且 target_type=schedule_event 且 target_id>0 才算成功。
|
||||
// 2. 其余情况都视为需要继续 ask_user。
|
||||
func (r Result) IsResolved() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(r.Action), ActionSelectCandidate) &&
|
||||
strings.EqualFold(strings.TrimSpace(r.TargetType), TargetTypeScheduleEvent) &&
|
||||
r.TargetID > 0
|
||||
}
|
||||
|
||||
// ShouldAskUser 表示本次定位是否应该回退为追问。
|
||||
func (r Result) ShouldAskUser() bool {
|
||||
return !r.IsResolved()
|
||||
}
|
||||
|
||||
type promptInput struct {
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
UserMessage string `json:"user_message"`
|
||||
PendingQuestion string `json:"pending_question,omitempty"`
|
||||
MissingInfo []string `json:"missing_info,omitempty"`
|
||||
Window promptWindowInput `json:"window"`
|
||||
Candidates []eventCandidate `json:"candidates"`
|
||||
}
|
||||
|
||||
type promptWindowInput struct {
|
||||
StartAt string `json:"start_at"`
|
||||
EndAt string `json:"end_at"`
|
||||
}
|
||||
|
||||
type eventCandidate struct {
|
||||
TargetID int `json:"target_id"`
|
||||
Title string `json:"title"`
|
||||
SourceType string `json:"source_type,omitempty"`
|
||||
RelatedID int `json:"related_id,omitempty"`
|
||||
SlotSummary string `json:"slot_summary,omitempty"`
|
||||
}
|
||||
|
||||
type llmResponse struct {
|
||||
Action string `json:"action"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID int `json:"target_id"`
|
||||
Reason string `json:"reason"`
|
||||
AskUserQuestion string `json:"ask_user_question"`
|
||||
}
|
||||
69
backend/active_scheduler/feedbacklocate/prompt.go
Normal file
69
backend/active_scheduler/feedbacklocate/prompt.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package feedbacklocate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const locateSystemPrompt = `
|
||||
你是 SmartFlow 主动调度里专门负责 unfinished_feedback 的定位器。
|
||||
你的任务只有一个:根据用户补充的话,把它定位到当前滚动窗口中的某一个 schedule_event;定位不了就继续 ask_user。
|
||||
|
||||
硬规则:
|
||||
1. 只允许输出 JSON,不要输出 markdown,不要输出解释性正文。
|
||||
2. 只允许返回 action / target_type / target_id / reason / ask_user_question 这几个字段。
|
||||
3. target_type 只能是 schedule_event。
|
||||
4. target_id 必须来自候选列表里的 target_id,不要编造,不要猜一个新的。
|
||||
5. 当你不能稳定定位时,action 必须是 ask_user,并给出一句短问题。
|
||||
6. 当用户补充信息已经足够时,action 必须是 select_candidate。
|
||||
7. 请优先结合当前时间、用户原始补充话术、pending question 和候选日程的时间顺序来判断。
|
||||
`
|
||||
|
||||
func buildPromptInput(req Request, generatedAt string, windowStart string, windowEnd string, candidates []eventCandidate) promptInput {
|
||||
input := promptInput{
|
||||
GeneratedAt: generatedAt,
|
||||
UserMessage: strings.TrimSpace(req.UserMessage),
|
||||
Window: promptWindowInput{
|
||||
StartAt: windowStart,
|
||||
EndAt: windowEnd,
|
||||
},
|
||||
}
|
||||
|
||||
if trimmed := strings.TrimSpace(req.PendingQuestion); trimmed != "" {
|
||||
input.PendingQuestion = trimmed
|
||||
}
|
||||
if len(req.MissingInfo) > 0 {
|
||||
input.MissingInfo = cloneAndTrimStrings(req.MissingInfo)
|
||||
}
|
||||
input.Candidates = append([]eventCandidate(nil), candidates...)
|
||||
return input
|
||||
}
|
||||
|
||||
func buildUserPrompt(input promptInput) (string, error) {
|
||||
raw, err := json.MarshalIndent(input, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
builder.WriteString("请根据输入定位当前滚动窗口中的 schedule_event。")
|
||||
builder.WriteString("只输出 JSON,不要补充任何其它内容。\n")
|
||||
builder.WriteString("输入:\n")
|
||||
builder.WriteString(string(raw))
|
||||
return builder.String(), nil
|
||||
}
|
||||
|
||||
// BuildAskUserQuestion 负责把 missing_info 转成继续追问用户的短问题。
|
||||
func BuildAskUserQuestion(missingInfo []string) string {
|
||||
normalized := cloneAndTrimStrings(missingInfo)
|
||||
if len(normalized) == 0 {
|
||||
return "请补充能唯一定位到未完成日程块的信息。"
|
||||
}
|
||||
|
||||
for _, item := range normalized {
|
||||
if item == "feedback_target" {
|
||||
return "请告诉我你指的是哪一个未完成的日程块,比如具体时间或名称。"
|
||||
}
|
||||
}
|
||||
return "请补充 " + strings.Join(normalized, "、") + " 对应的信息。"
|
||||
}
|
||||
367
backend/active_scheduler/feedbacklocate/service.go
Normal file
367
backend/active_scheduler/feedbacklocate/service.go
Normal file
@@ -0,0 +1,367 @@
|
||||
package feedbacklocate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/active_scheduler/ports"
|
||||
"github.com/LoveLosita/smartflow/backend/active_scheduler/trigger"
|
||||
infrallm "github.com/LoveLosita/smartflow/backend/infra/llm"
|
||||
)
|
||||
|
||||
const locateMaxTokens = 800
|
||||
|
||||
// Service 负责把 unfinished_feedback 的补充话术定位到当前滚动窗口内的 schedule_event。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做“定位”与“继续追问”的判断,不负责正式日程写入。
|
||||
// 2. 候选来自 ScheduleReader,JSON 判定来自 LLM,二者任一不可用时都回退为 ask_user。
|
||||
// 3. 不创建新工具系统,也不直接产出 preview。
|
||||
type Service struct {
|
||||
reader ports.ScheduleReader
|
||||
client *infrallm.Client
|
||||
clock func() time.Time
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// NewService 创建反馈定位服务。
|
||||
//
|
||||
// 说明:
|
||||
// 1. reader / client 允许为空,方便在模型不可用或读模型暂时不可用时直接回退 ask_user。
|
||||
// 2. 真正的定位能力只在 Resolve 内部按需启用。
|
||||
func NewService(reader ports.ScheduleReader, client *infrallm.Client) *Service {
|
||||
return &Service{
|
||||
reader: reader,
|
||||
client: client,
|
||||
clock: time.Now,
|
||||
logger: log.Default(),
|
||||
}
|
||||
}
|
||||
|
||||
// SetClock 允许测试注入稳定时间。
|
||||
func (s *Service) SetClock(clock func() time.Time) {
|
||||
if s != nil && clock != nil {
|
||||
s.clock = clock
|
||||
}
|
||||
}
|
||||
|
||||
// SetLogger 允许外部替换日志器。
|
||||
func (s *Service) SetLogger(logger *log.Logger) {
|
||||
if s != nil && logger != nil {
|
||||
s.logger = logger
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve 负责把用户补充信息定位到当前滚动窗口中的一个 schedule_event。
|
||||
//
|
||||
// 输入输出语义:
|
||||
// 1. 成功时返回 action=select_candidate,且 target_type=schedule_event、target_id 可校验。
|
||||
// 2. 失败时不硬猜,统一返回 action=ask_user。
|
||||
// 3. 只有上下文取消这类外部中断才会返回 error。
|
||||
func (s *Service) Resolve(ctx context.Context, req Request) (Result, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if req.UserID <= 0 {
|
||||
return Result{}, errors.New("feedback locate user_id 不能为空")
|
||||
}
|
||||
|
||||
now := s.now()
|
||||
windowStart := now
|
||||
windowEnd := now.Add(24 * time.Hour)
|
||||
|
||||
candidates, err := s.loadCandidates(ctx, req.UserID, windowStart, windowEnd, now)
|
||||
if err != nil {
|
||||
return s.buildAskUserResult(req, "读取滚动窗口日程失败: "+err.Error()), nil
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return s.buildAskUserResult(req, "当前滚动窗口内没有可定位的日程块"), nil
|
||||
}
|
||||
|
||||
if s == nil || s.client == nil {
|
||||
return s.buildAskUserResult(req, "模型暂不可用"), nil
|
||||
}
|
||||
|
||||
userPrompt, err := buildUserPrompt(buildPromptInput(
|
||||
req,
|
||||
now.In(time.Local).Format(time.RFC3339),
|
||||
windowStart.In(time.Local).Format(time.RFC3339),
|
||||
windowEnd.In(time.Local).Format(time.RFC3339),
|
||||
candidates,
|
||||
))
|
||||
if err != nil {
|
||||
return s.buildAskUserResult(req, "定位 prompt 构造失败"), nil
|
||||
}
|
||||
|
||||
messages := infrallm.BuildSystemUserMessages(strings.TrimSpace(locateSystemPrompt), nil, userPrompt)
|
||||
resp, rawResult, err := infrallm.GenerateJSON[llmResponse](
|
||||
ctx,
|
||||
s.client,
|
||||
messages,
|
||||
infrallm.GenerateOptions{
|
||||
Temperature: 0.1,
|
||||
MaxTokens: locateMaxTokens,
|
||||
Thinking: infrallm.ThinkingModeDisabled,
|
||||
Metadata: map[string]any{
|
||||
"stage": "active_scheduler_feedback_locate",
|
||||
"candidate_count": len(candidates),
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if s.logger != nil {
|
||||
s.logger.Printf("[WARN] active scheduler feedback locate failed: err=%v raw=%s", err, truncateRaw(rawResult))
|
||||
}
|
||||
return s.buildAskUserResult(req, "模型定位失败"), nil
|
||||
}
|
||||
|
||||
result, fallbackUsed := s.convertResponse(req, resp, candidates)
|
||||
if fallbackUsed && s.logger != nil {
|
||||
selectedID := 0
|
||||
action := ""
|
||||
targetType := ""
|
||||
if resp != nil {
|
||||
selectedID = resp.TargetID
|
||||
action = strings.TrimSpace(resp.Action)
|
||||
targetType = strings.TrimSpace(resp.TargetType)
|
||||
}
|
||||
s.logger.Printf("[WARN] active scheduler feedback locate fallback: action=%q target_type=%q target_id=%d", action, targetType, selectedID)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) convertResponse(req Request, resp *llmResponse, candidates []eventCandidate) (Result, bool) {
|
||||
if resp == nil {
|
||||
return s.buildAskUserResult(req, "模型返回空结果"), true
|
||||
}
|
||||
|
||||
candidateMap := make(map[int]eventCandidate, len(candidates))
|
||||
for _, item := range candidates {
|
||||
candidateMap[item.TargetID] = item
|
||||
}
|
||||
|
||||
action := normalizeAction(resp.Action)
|
||||
targetType := strings.TrimSpace(resp.TargetType)
|
||||
targetID := resp.TargetID
|
||||
reason := strings.TrimSpace(resp.Reason)
|
||||
askUserQuestion := strings.TrimSpace(resp.AskUserQuestion)
|
||||
|
||||
if action == ActionSelectCandidate &&
|
||||
strings.EqualFold(targetType, TargetTypeScheduleEvent) &&
|
||||
targetID > 0 {
|
||||
if _, ok := candidateMap[targetID]; ok {
|
||||
return Result{
|
||||
Action: ActionSelectCandidate,
|
||||
TargetType: TargetTypeScheduleEvent,
|
||||
TargetID: targetID,
|
||||
Reason: reason,
|
||||
AskUserQuestion: "",
|
||||
}, false
|
||||
}
|
||||
}
|
||||
|
||||
question := firstNonEmptyString(
|
||||
askUserQuestion,
|
||||
BuildAskUserQuestion(req.MissingInfo),
|
||||
req.PendingQuestion,
|
||||
)
|
||||
return Result{
|
||||
Action: ActionAskUser,
|
||||
TargetType: TargetTypeScheduleEvent,
|
||||
TargetID: 0,
|
||||
Reason: reason,
|
||||
AskUserQuestion: question,
|
||||
}, true
|
||||
}
|
||||
|
||||
func (s *Service) buildAskUserResult(req Request, reason string) Result {
|
||||
return Result{
|
||||
Action: ActionAskUser,
|
||||
TargetType: TargetTypeScheduleEvent,
|
||||
TargetID: 0,
|
||||
Reason: strings.TrimSpace(reason),
|
||||
AskUserQuestion: firstNonEmptyString(BuildAskUserQuestion(req.MissingInfo), req.PendingQuestion),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) loadCandidates(ctx context.Context, userID int, windowStart time.Time, windowEnd time.Time, now time.Time) ([]eventCandidate, error) {
|
||||
if s == nil || s.reader == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
facts, err := s.reader.GetScheduleFactsByWindow(ctx, ports.ScheduleWindowRequest{
|
||||
UserID: userID,
|
||||
TargetType: string(trigger.TargetTypeScheduleEvent),
|
||||
TargetID: 0,
|
||||
WindowStart: windowStart,
|
||||
WindowEnd: windowEnd,
|
||||
Now: now,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildEventCandidates(facts.Events), nil
|
||||
}
|
||||
|
||||
func buildEventCandidates(events []ports.ScheduleEventFact) []eventCandidate {
|
||||
if len(events) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
sorted := append([]ports.ScheduleEventFact(nil), events...)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
return eventBefore(sorted[i], sorted[j])
|
||||
})
|
||||
|
||||
candidates := make([]eventCandidate, 0, len(sorted))
|
||||
for _, item := range sorted {
|
||||
candidates = append(candidates, eventCandidate{
|
||||
TargetID: item.ID,
|
||||
Title: strings.TrimSpace(item.Title),
|
||||
SourceType: strings.TrimSpace(item.SourceType),
|
||||
RelatedID: item.RelID,
|
||||
SlotSummary: summarizeSlots(item.Slots),
|
||||
})
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func eventBefore(left, right ports.ScheduleEventFact) bool {
|
||||
leftStart := firstSlotStart(left.Slots)
|
||||
rightStart := firstSlotStart(right.Slots)
|
||||
if !leftStart.IsZero() && !rightStart.IsZero() && !leftStart.Equal(rightStart) {
|
||||
return leftStart.Before(rightStart)
|
||||
}
|
||||
if left.ID != right.ID {
|
||||
return left.ID < right.ID
|
||||
}
|
||||
return strings.TrimSpace(left.Title) < strings.TrimSpace(right.Title)
|
||||
}
|
||||
|
||||
func firstSlotStart(slots []ports.Slot) time.Time {
|
||||
if len(slots) == 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
sorted := append([]ports.Slot(nil), slots...)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
return slotBefore(sorted[i], sorted[j])
|
||||
})
|
||||
return sorted[0].StartAt
|
||||
}
|
||||
|
||||
func slotBefore(left, right ports.Slot) bool {
|
||||
if !left.StartAt.IsZero() && !right.StartAt.IsZero() && !left.StartAt.Equal(right.StartAt) {
|
||||
return left.StartAt.Before(right.StartAt)
|
||||
}
|
||||
if left.Week != right.Week {
|
||||
return left.Week < right.Week
|
||||
}
|
||||
if left.DayOfWeek != right.DayOfWeek {
|
||||
return left.DayOfWeek < right.DayOfWeek
|
||||
}
|
||||
return left.Section < right.Section
|
||||
}
|
||||
|
||||
func summarizeSlots(slots []ports.Slot) string {
|
||||
if len(slots) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
sorted := append([]ports.Slot(nil), slots...)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
return slotBefore(sorted[i], sorted[j])
|
||||
})
|
||||
|
||||
parts := make([]string, 0, minInt(3, len(sorted)))
|
||||
for idx, slot := range sorted {
|
||||
if idx >= 3 {
|
||||
break
|
||||
}
|
||||
parts = append(parts, summarizeSlot(slot))
|
||||
}
|
||||
if len(sorted) > 3 {
|
||||
parts = append(parts, "...")
|
||||
}
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
|
||||
func summarizeSlot(slot ports.Slot) string {
|
||||
if !slot.StartAt.IsZero() && !slot.EndAt.IsZero() {
|
||||
return fmt.Sprintf("%s-%s", slot.StartAt.In(time.Local).Format("01-02 15:04"), slot.EndAt.In(time.Local).Format("15:04"))
|
||||
}
|
||||
return fmt.Sprintf("W%d-D%d-S%d", slot.Week, slot.DayOfWeek, slot.Section)
|
||||
}
|
||||
|
||||
func normalizeAction(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case ActionSelectCandidate:
|
||||
return ActionSelectCandidate
|
||||
case ActionAskUser:
|
||||
return ActionAskUser
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cloneAndTrimStrings(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, item := range values {
|
||||
trimmed := strings.TrimSpace(item)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func truncateRaw(raw *infrallm.TextResult) string {
|
||||
if raw == nil {
|
||||
return ""
|
||||
}
|
||||
text := strings.TrimSpace(raw.Text)
|
||||
runes := []rune(text)
|
||||
if len(runes) <= 200 {
|
||||
return text
|
||||
}
|
||||
return string(runes[:200]) + "..."
|
||||
}
|
||||
|
||||
func (s *Service) now() time.Time {
|
||||
if s == nil || s.clock == nil {
|
||||
return time.Now()
|
||||
}
|
||||
return s.clock()
|
||||
}
|
||||
|
||||
func minInt(left, right int) int {
|
||||
if left < right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
activeadapters "github.com/LoveLosita/smartflow/backend/active_scheduler/adapters"
|
||||
"github.com/LoveLosita/smartflow/backend/active_scheduler/applyadapter"
|
||||
activefeedbacklocate "github.com/LoveLosita/smartflow/backend/active_scheduler/feedbacklocate"
|
||||
activegraph "github.com/LoveLosita/smartflow/backend/active_scheduler/graph"
|
||||
activejob "github.com/LoveLosita/smartflow/backend/active_scheduler/job"
|
||||
activepreview "github.com/LoveLosita/smartflow/backend/active_scheduler/preview"
|
||||
@@ -223,6 +224,7 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) {
|
||||
taskRepo,
|
||||
cacheRepo,
|
||||
agentCacheRepo,
|
||||
manager.ActiveSchedule,
|
||||
manager.ActiveScheduleSession,
|
||||
eventBus,
|
||||
scheduleService,
|
||||
@@ -258,11 +260,12 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) {
|
||||
// 2. dry-run 与 selection 通过 graph runner 串起来,避免 trigger_pipeline 再拼第二套候选逻辑。
|
||||
activeScheduleLLMClient := infrallm.WrapArkClient(aiHub.Pro)
|
||||
activeScheduleSelector := activesel.NewService(activeScheduleLLMClient)
|
||||
activeScheduleFeedbackLocator := activefeedbacklocate.NewService(activeReaders, activeScheduleLLMClient)
|
||||
activeScheduleGraphRunner, err := activegraph.NewRunner(activeScheduleDryRun.AsGraphDryRunFunc(), activeScheduleSelector)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agentService.SetActiveScheduleSessionRerunFunc(buildActiveScheduleSessionRerunFunc(manager.ActiveSchedule, activeScheduleGraphRunner, activeSchedulePreviewConfirm))
|
||||
agentService.SetActiveScheduleSessionRerunFunc(buildActiveScheduleSessionRerunFunc(manager.ActiveSchedule, activeScheduleGraphRunner, activeSchedulePreviewConfirm, activeScheduleFeedbackLocator))
|
||||
// 1. 生产投递先切到用户级飞书 Webhook provider,mock provider 文件继续保留给后续单测和本地隔离验证。
|
||||
// 2. provider 与配置测试接口共用同一个实例,保证“测试成功”和“正式投递”走同一套 URL 校验、JSON 拼装和 HTTP 结果分类。
|
||||
feishuProvider, err := notification.NewWebhookFeishuProvider(manager.Notification, notification.WebhookFeishuProviderOptions{
|
||||
@@ -390,16 +393,17 @@ func buildActiveSchedulePreviewConfirmService(db *gorm.DB, activeDAO *dao.Active
|
||||
return activesvc.NewPreviewConfirmService(dryRun, previewService, activeDAO, applyadapter.NewGormApplyAdapter(db))
|
||||
}
|
||||
|
||||
// buildActiveScheduleSessionRerunFunc 把主动调度 graph / preview 能力装成聊天入口可调用的 rerun 闭包。
|
||||
// buildActiveScheduleSessionRerunFunc 把主动调度定位器 / graph / preview 能力装成聊天入口可调用的 rerun 闭包。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 这里只做最小接线:复用现有 trigger -> graph -> preview 组件,不把 worker/notification 再搬一遍;
|
||||
// 1. 这里只做最小接线:复用现有定位器 -> trigger -> graph -> preview 组件,不把 worker/notification 再搬一遍;
|
||||
// 2. 成功时返回 session 状态、assistant 文本和业务卡片数据;
|
||||
// 3. 失败时直接把 error 交回聊天入口,由上层统一写失败日志和 SSE 错误。
|
||||
func buildActiveScheduleSessionRerunFunc(
|
||||
activeDAO *dao.ActiveScheduleDAO,
|
||||
graphRunner *activegraph.Runner,
|
||||
previewConfirm *activesvc.PreviewConfirmService,
|
||||
feedbackLocator *activefeedbacklocate.Service,
|
||||
) agentsvcsvc.ActiveScheduleSessionRerunFunc {
|
||||
return func(
|
||||
ctx context.Context,
|
||||
@@ -419,16 +423,74 @@ func buildActiveScheduleSessionRerunFunc(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 1. 当前最小接线先复用“原 trigger + 最新数据库事实”重跑 active scheduler graph。
|
||||
// 2. 用户这次回复的正文已经由聊天入口写进 conversation/timeline,但还没有下沉到 active_scheduler readers。
|
||||
// 3. 后续若要让 ask_user 回复直接改写 graph 事实源,应在 reader/context builder 层继续补这一跳。
|
||||
resolvedTargetType := activeTrigger.TargetType(triggerRow.TargetType)
|
||||
resolvedTargetID := triggerRow.TargetID
|
||||
needsFeedbackLocate := activeTrigger.TriggerType(triggerRow.TriggerType) == activeTrigger.TriggerTypeUnfinishedFeedback &&
|
||||
(resolvedTargetID <= 0 || containsString(session.State.MissingInfo, "feedback_target"))
|
||||
|
||||
// 1. unfinished_feedback 在目标缺失时先走定位器,把用户补充信息转成可校验的 schedule_event。
|
||||
// 2. 定位失败时直接 ask_user,不硬猜 target_id,也不继续跑 graph。
|
||||
// 3. 定位成功后只改本次 domainTrigger 的 target_type / target_id,不写正式日程。
|
||||
if needsFeedbackLocate {
|
||||
if feedbackLocator == nil {
|
||||
question := firstNonEmptyString(
|
||||
activefeedbacklocate.BuildAskUserQuestion(session.State.MissingInfo),
|
||||
session.State.PendingQuestion,
|
||||
)
|
||||
nextState := session.State
|
||||
nextState.PendingQuestion = question
|
||||
nextState.MissingInfo = appendMissingString(nextState.MissingInfo, "feedback_target")
|
||||
nextState.LastCandidateID = ""
|
||||
nextState.LastNotificationID = ""
|
||||
nextState.FailedReason = ""
|
||||
nextState.ExpiresAt = nil
|
||||
return &agentsvcsvc.ActiveScheduleSessionRerunResult{
|
||||
AssistantText: question,
|
||||
SessionState: nextState,
|
||||
SessionStatus: model.ActiveScheduleSessionStatusWaitingUserReply,
|
||||
}, nil
|
||||
}
|
||||
locateResult, locateErr := feedbackLocator.Resolve(ctx, activefeedbacklocate.Request{
|
||||
UserID: triggerRow.UserID,
|
||||
UserMessage: userMessage,
|
||||
PendingQuestion: session.State.PendingQuestion,
|
||||
MissingInfo: cloneStringSlice(session.State.MissingInfo),
|
||||
})
|
||||
if locateErr != nil {
|
||||
return nil, locateErr
|
||||
}
|
||||
if locateResult.ShouldAskUser() {
|
||||
question := firstNonEmptyString(
|
||||
locateResult.AskUserQuestion,
|
||||
activefeedbacklocate.BuildAskUserQuestion(session.State.MissingInfo),
|
||||
session.State.PendingQuestion,
|
||||
)
|
||||
nextState := session.State
|
||||
nextState.PendingQuestion = question
|
||||
nextState.MissingInfo = appendMissingString(nextState.MissingInfo, "feedback_target")
|
||||
nextState.LastCandidateID = ""
|
||||
nextState.LastNotificationID = ""
|
||||
nextState.FailedReason = ""
|
||||
nextState.ExpiresAt = nil
|
||||
return &agentsvcsvc.ActiveScheduleSessionRerunResult{
|
||||
AssistantText: question,
|
||||
SessionState: nextState,
|
||||
SessionStatus: model.ActiveScheduleSessionStatusWaitingUserReply,
|
||||
}, nil
|
||||
}
|
||||
resolvedTargetType = activeTrigger.TargetType(locateResult.TargetType)
|
||||
resolvedTargetID = locateResult.TargetID
|
||||
}
|
||||
|
||||
// 1. 定位完成后再构造 domainTrigger,避免 unfinished_feedback 的 target_id 为空时误触校验失败。
|
||||
// 2. 这里仍然复用现有 graph -> preview 链路,不写新排程引擎。
|
||||
domainTrigger := activeTrigger.ActiveScheduleTrigger{
|
||||
TriggerID: triggerRow.ID,
|
||||
UserID: triggerRow.UserID,
|
||||
TriggerType: activeTrigger.TriggerType(triggerRow.TriggerType),
|
||||
Source: activeTrigger.SourceUserFeedback,
|
||||
TargetType: activeTrigger.TargetType(triggerRow.TargetType),
|
||||
TargetID: triggerRow.TargetID,
|
||||
TargetType: resolvedTargetType,
|
||||
TargetID: resolvedTargetID,
|
||||
FeedbackID: triggerRow.FeedbackID,
|
||||
IdempotencyKey: triggerRow.IdempotencyKey,
|
||||
MockNow: nil,
|
||||
@@ -550,6 +612,35 @@ func cloneStringSlice(values []string) []string {
|
||||
return copied
|
||||
}
|
||||
|
||||
// appendMissingString 负责把缺失字段名补回状态数组,避免 ask_user 分支把原始缺失项冲掉。
|
||||
func appendMissingString(values []string, next string) []string {
|
||||
trimmed := strings.TrimSpace(next)
|
||||
if trimmed == "" {
|
||||
return cloneStringSlice(values)
|
||||
}
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == trimmed {
|
||||
return cloneStringSlice(values)
|
||||
}
|
||||
}
|
||||
result := cloneStringSlice(values)
|
||||
return append(result, trimmed)
|
||||
}
|
||||
|
||||
// containsString 负责判断 missing_info 里是否已经标记过某个缺失项。
|
||||
func containsString(values []string, target string) bool {
|
||||
trimmed := strings.TrimSpace(target)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == trimmed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func configureAgentService(
|
||||
agentService *service.AgentService,
|
||||
ragRuntime infrarag.Runtime,
|
||||
|
||||
@@ -192,6 +192,44 @@ func (d *ActiveScheduleSessionDAO) UpdateActiveScheduleSessionFieldsBySessionID(
|
||||
Updates(normalizedUpdates).Error
|
||||
}
|
||||
|
||||
// TryTransitionActiveScheduleSessionStatusBySessionID 按 session_id 原子切换主动调度会话状态。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责“当前状态仍为 fromStatus 时才切到 toStatus”的轻量 CAS,不写 state_json 和 preview_id;
|
||||
// 2. 返回 true 表示本次调用抢到了状态推进权,可以继续执行后续 rerun;
|
||||
// 3. 返回 false 表示已有其他请求先推进了状态,调用方应降级为占管提示,避免重复生成 preview。
|
||||
func (d *ActiveScheduleSessionDAO) TryTransitionActiveScheduleSessionStatusBySessionID(ctx context.Context, sessionID string, fromStatus string, toStatus string) (bool, error) {
|
||||
if err := d.ensureDB(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
normalizedSessionID := strings.TrimSpace(sessionID)
|
||||
if normalizedSessionID == "" {
|
||||
return false, errors.New("session_id is empty")
|
||||
}
|
||||
|
||||
normalizedFrom, err := normalizeActiveScheduleSessionStatus(fromStatus)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid active schedule session from status: %w", err)
|
||||
}
|
||||
normalizedTo, err := normalizeActiveScheduleSessionStatus(toStatus)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid active schedule session to status: %w", err)
|
||||
}
|
||||
|
||||
result := d.db.WithContext(ctx).
|
||||
Model(&model.ActiveScheduleSession{}).
|
||||
Where("session_id = ? AND status = ?", normalizedSessionID, normalizedFrom).
|
||||
Updates(map[string]any{
|
||||
"status": normalizedTo,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return false, result.Error
|
||||
}
|
||||
return result.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// UpdateActiveScheduleSessionFieldsByConversationID 按 user_id + conversation_id 更新最新记录的局部字段。
|
||||
//
|
||||
// 步骤化说明:
|
||||
|
||||
@@ -25,10 +25,11 @@ func NewAgentService(
|
||||
taskRepo *dao.TaskDAO,
|
||||
cacheDAO *dao.CacheDAO,
|
||||
agentRedis *dao.AgentCache,
|
||||
activeScheduleDAO *dao.ActiveScheduleDAO,
|
||||
activeSessionDAO *dao.ActiveScheduleSessionDAO,
|
||||
eventPublisher outboxinfra.EventPublisher,
|
||||
) *AgentService {
|
||||
return agentsvc.NewAgentService(aiHub, repo, taskRepo, cacheDAO, agentRedis, activeSessionDAO, eventPublisher)
|
||||
return agentsvc.NewAgentService(aiHub, repo, taskRepo, cacheDAO, agentRedis, activeScheduleDAO, activeSessionDAO, eventPublisher)
|
||||
}
|
||||
|
||||
// NewAgentServiceWithSchedule 在基础 AgentService 上注入排程依赖。
|
||||
@@ -43,12 +44,13 @@ func NewAgentServiceWithSchedule(
|
||||
taskRepo *dao.TaskDAO,
|
||||
cacheDAO *dao.CacheDAO,
|
||||
agentRedis *dao.AgentCache,
|
||||
activeScheduleDAO *dao.ActiveScheduleDAO,
|
||||
activeSessionDAO *dao.ActiveScheduleSessionDAO,
|
||||
eventPublisher outboxinfra.EventPublisher,
|
||||
scheduleSvc *ScheduleService,
|
||||
taskSvc *TaskService,
|
||||
) *AgentService {
|
||||
svc := agentsvc.NewAgentService(aiHub, repo, taskRepo, cacheDAO, agentRedis, activeSessionDAO, eventPublisher)
|
||||
svc := agentsvc.NewAgentService(aiHub, repo, taskRepo, cacheDAO, agentRedis, activeScheduleDAO, activeSessionDAO, eventPublisher)
|
||||
|
||||
// 注入排程依赖:将 service 层方法包装为函数闭包,避免循环依赖。
|
||||
if scheduleSvc != nil {
|
||||
|
||||
@@ -31,6 +31,7 @@ type AgentService struct {
|
||||
taskRepo *dao.TaskDAO
|
||||
cacheDAO *dao.CacheDAO
|
||||
agentCache *dao.AgentCache
|
||||
activeScheduleDAO *dao.ActiveScheduleDAO
|
||||
activeScheduleSessionDAO *dao.ActiveScheduleSessionDAO
|
||||
eventPublisher outboxinfra.EventPublisher
|
||||
|
||||
@@ -79,6 +80,7 @@ func NewAgentService(
|
||||
taskRepo *dao.TaskDAO,
|
||||
cacheDAO *dao.CacheDAO,
|
||||
agentRedis *dao.AgentCache,
|
||||
activeScheduleDAO *dao.ActiveScheduleDAO,
|
||||
activeSessionDAO *dao.ActiveScheduleSessionDAO,
|
||||
eventPublisher outboxinfra.EventPublisher,
|
||||
) *AgentService {
|
||||
@@ -93,6 +95,7 @@ func NewAgentService(
|
||||
taskRepo: taskRepo,
|
||||
cacheDAO: cacheDAO,
|
||||
agentCache: agentRedis,
|
||||
activeScheduleDAO: activeScheduleDAO,
|
||||
activeScheduleSessionDAO: activeSessionDAO,
|
||||
eventPublisher: eventPublisher,
|
||||
}
|
||||
|
||||
@@ -114,6 +114,27 @@ func (s *AgentService) persistActiveScheduleSessionBestEffort(ctx context.Contex
|
||||
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 占管的聊天入口。
|
||||
//
|
||||
// 步骤化说明:
|
||||
@@ -165,20 +186,36 @@ func (s *AgentService) handleActiveScheduleSessionChat(
|
||||
}
|
||||
// 1. 收到用户补充信息后,先把 session 切成 rerunning,避免并发请求继续按旧状态走普通聊天。
|
||||
// 2. 这个阶段只是状态切换,不代表 graph 已经完成。
|
||||
session.Status = model.ActiveScheduleSessionStatusRerunning
|
||||
if err := s.persistActiveScheduleSessionBestEffort(ctx, session); err != nil {
|
||||
// 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 != "" {
|
||||
assistantText := "主动调度正在重新生成建议,请稍后再试。"
|
||||
if err := s.persistNewAgentConversationMessage(ctx, userID, chatID, schema.AssistantMessage(assistantText, nil), 0); err != nil {
|
||||
if err := s.respondActiveScheduleRerunning(ctx, userID, chatID, traceID, resolvedModelName, requestStart, outChan); err != nil {
|
||||
return true, err
|
||||
}
|
||||
emitActiveScheduleAssistantChunk(outChan, traceID, resolvedModelName, requestStart, assistantText, nil)
|
||||
}
|
||||
return true, nil
|
||||
default:
|
||||
@@ -186,6 +223,29 @@ func (s *AgentService) handleActiveScheduleSessionChat(
|
||||
}
|
||||
}
|
||||
|
||||
// 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 的用户补充同步推进成新的主动调度结果。
|
||||
//
|
||||
// 职责边界:
|
||||
@@ -230,10 +290,9 @@ func (s *AgentService) runActiveScheduleSessionRerun(
|
||||
}
|
||||
session.Status = finalStatus
|
||||
session.State = result.SessionState
|
||||
if strings.TrimSpace(result.PreviewID) != "" {
|
||||
session.CurrentPreviewID = strings.TrimSpace(result.PreviewID)
|
||||
} else if session.Status != model.ActiveScheduleSessionStatusReadyPreview {
|
||||
session.CurrentPreviewID = ""
|
||||
previewID := strings.TrimSpace(result.PreviewID)
|
||||
if previewID != "" {
|
||||
session.CurrentPreviewID = previewID
|
||||
}
|
||||
if session.Status == model.ActiveScheduleSessionStatusReadyPreview {
|
||||
session.State.PendingQuestion = ""
|
||||
@@ -241,6 +300,12 @@ func (s *AgentService) runActiveScheduleSessionRerun(
|
||||
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
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ func (p ActiveScheduleTriggeredPayload) Validate() error {
|
||||
if !isAllowedActiveScheduleTargetType(p.TargetType) {
|
||||
return errors.New("target_type 不在主动调度第一版允许范围内")
|
||||
}
|
||||
if p.TargetID <= 0 {
|
||||
if p.TargetID <= 0 && strings.TrimSpace(p.TriggerType) != ActiveScheduleTriggerTypeUnfinishedFeedback {
|
||||
return errors.New("target_id 必须大于 0")
|
||||
}
|
||||
if p.RequestedAt.IsZero() {
|
||||
|
||||
Reference in New Issue
Block a user