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:
Losita
2026-05-02 12:41:50 +08:00
parent a3eaa9b2c2
commit ba23ebd201
12 changed files with 891 additions and 103 deletions

View 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"`
}

View 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, "、") + " 对应的信息。"
}

View 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. 候选来自 ScheduleReaderJSON 判定来自 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
}