后端:
1. 主动调度 graph + session bridge 收口——把 dry-run / select / preview / confirm / rerun 串成受限 graph,新增 active_schedule_sessions 缓存与聊天拦截,ready_preview 后释放回自由聊天
2. 会话与通知链路对齐——notification 统一绑定 conversation_id,action_url 指向 /assistant/{conversation_id},会话不存在改回 404 语义,避免 wrong param type 误导排障
3. estimated_sections 写入与主动调度消费链路补齐——任务创建、quick task 与随口记入口都透传估计节数,主动调度只消费落库值
前端:
4. AssistantPanel 最小适配主动调度预览与失败态——复用主动调度卡片/微调弹窗,补历史加载失败可见提示与跨账号会话拦截
文档:
5. 更新主动调度缺口分阶段实施计划和实现方案,标记阶段 0-2 收口并同步接力状态
304 lines
8.4 KiB
Go
304 lines
8.4 KiB
Go
package selection
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"math"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/LoveLosita/smartflow/backend/active_scheduler/candidate"
|
||
infrallm "github.com/LoveLosita/smartflow/backend/infra/llm"
|
||
)
|
||
|
||
const selectionMaxTokens = 1200
|
||
|
||
// Service 负责主动调度候选的受限 LLM 选择。
|
||
//
|
||
// 职责边界:
|
||
// 1. 只在后端候选中做选择和解释生成,不生成新候选;
|
||
// 2. LLM 失败、输出非法或选择不存在候选时,回退到后端 fallback candidate;
|
||
// 3. 不写 preview、不发通知、不修改正式日程。
|
||
type Service struct {
|
||
client *infrallm.Client
|
||
clock func() time.Time
|
||
logger *log.Logger
|
||
}
|
||
|
||
// NewService 创建主动调度选择器。
|
||
//
|
||
// 说明:
|
||
// 1. client 允许为空;为空时选择器只走确定性 fallback,便于本地测试和降级;
|
||
// 2. 真正的模型接入在 cmd/start.go 中完成:aiHub.Pro -> llm.Client -> selection.Service;
|
||
// 3. 选择器本身不持有模型配置,只表达本业务域的 prompt 和结果校验。
|
||
func NewService(client *infrallm.Client) *Service {
|
||
return &Service{
|
||
client: client,
|
||
clock: time.Now,
|
||
logger: log.Default(),
|
||
}
|
||
}
|
||
|
||
func (s *Service) SetClock(clock func() time.Time) {
|
||
if s != nil && clock != nil {
|
||
s.clock = clock
|
||
}
|
||
}
|
||
|
||
// Select 对主动调度候选做有限选择。
|
||
//
|
||
// 步骤化说明:
|
||
// 1. 先校验 dry-run 输入,保证 LLM 不会拿到空上下文或空候选;
|
||
// 2. 若模型不可用,直接使用后端 fallback candidate,并显式标记 FallbackUsed;
|
||
// 3. 若模型可用,构造只读候选视图,隐藏 score / confidence / 原始事实快照;
|
||
// 4. 校验 LLM 返回的 candidate_id 是否存在,非法则回退;
|
||
// 5. 最终结果只交给 preview 层落库,不在这里产生任何副作用。
|
||
func (s *Service) Select(ctx context.Context, req SelectRequest) (Result, error) {
|
||
if err := validateRequest(req); err != nil {
|
||
return Result{}, err
|
||
}
|
||
|
||
if s == nil || s.client == nil {
|
||
return buildFallbackResult(req, "模型客户端未配置"), nil
|
||
}
|
||
|
||
input := buildSelectionPromptInput(req, s.now())
|
||
userPrompt, err := buildSelectionUserPrompt(input)
|
||
if err != nil {
|
||
return buildFallbackResult(req, "选择器 prompt 构造失败: "+err.Error()), nil
|
||
}
|
||
|
||
messages := infrallm.BuildSystemUserMessages(
|
||
strings.TrimSpace(selectionSystemPrompt),
|
||
nil,
|
||
userPrompt,
|
||
)
|
||
resp, rawResult, err := infrallm.GenerateJSON[llmSelectionResponse](
|
||
ctx,
|
||
s.client,
|
||
messages,
|
||
infrallm.GenerateOptions{
|
||
Temperature: 0.1,
|
||
MaxTokens: selectionMaxTokens,
|
||
Thinking: infrallm.ThinkingModeDisabled,
|
||
Metadata: map[string]any{
|
||
"stage": "active_scheduler_select",
|
||
"candidate_count": len(req.Candidates),
|
||
},
|
||
},
|
||
)
|
||
if err != nil {
|
||
if s.logger != nil {
|
||
s.logger.Printf("[WARN] 主动调度 LLM 选择失败,使用 fallback: err=%v raw=%s", err, truncateRaw(rawResult))
|
||
}
|
||
return buildFallbackResult(req, "模型选择失败: "+err.Error()), nil
|
||
}
|
||
|
||
result, fallbackUsed := convertLLMResponse(req, resp)
|
||
if fallbackUsed && s.logger != nil {
|
||
selectedCandidateID := ""
|
||
action := ""
|
||
if resp != nil {
|
||
selectedCandidateID = strings.TrimSpace(resp.SelectedCandidateID)
|
||
action = strings.TrimSpace(resp.Action)
|
||
}
|
||
s.logger.Printf("[WARN] 主动调度 LLM 选择结果非法,使用 fallback: selected=%q action=%q",
|
||
selectedCandidateID,
|
||
action,
|
||
)
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
type llmSelectionResponse struct {
|
||
Action string `json:"action"`
|
||
SelectedCandidateID string `json:"selected_candidate_id"`
|
||
Reason string `json:"reason"`
|
||
ExplanationText string `json:"explanation_text"`
|
||
NotificationSummary string `json:"notification_summary"`
|
||
AskUserQuestion string `json:"ask_user_question"`
|
||
}
|
||
|
||
func validateRequest(req SelectRequest) error {
|
||
if req.ActiveContext == nil {
|
||
return errors.New("active scheduler selection 缺少上下文")
|
||
}
|
||
if len(req.Candidates) == 0 {
|
||
return errors.New("active scheduler selection 缺少候选")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func convertLLMResponse(req SelectRequest, resp *llmSelectionResponse) (Result, bool) {
|
||
if resp == nil {
|
||
return buildFallbackResult(req, "模型返回空选择结果"), true
|
||
}
|
||
|
||
selected, ok := findCandidate(req.Candidates, resp.SelectedCandidateID)
|
||
if !ok {
|
||
return buildFallbackResult(req, "模型选择了不存在的候选"), true
|
||
}
|
||
|
||
inferredAction := inferAction(selected)
|
||
action := normalizeAction(resp.Action)
|
||
fallbackUsed := false
|
||
if action == "" || !isActionCompatible(action, selected.CandidateType) {
|
||
action = inferredAction
|
||
fallbackUsed = true
|
||
}
|
||
|
||
explanation := firstNonEmpty(resp.ExplanationText, selected.Summary)
|
||
notificationSummary := firstNonEmpty(resp.NotificationSummary, explanation, selected.Summary)
|
||
askUserQuestion := strings.TrimSpace(resp.AskUserQuestion)
|
||
if action == ActionAskUser && askUserQuestion == "" {
|
||
askUserQuestion = explanation
|
||
}
|
||
|
||
return Result{
|
||
Action: action,
|
||
SelectedCandidateID: selected.CandidateID,
|
||
Reason: strings.TrimSpace(resp.Reason),
|
||
ExplanationText: explanation,
|
||
NotificationSummary: notificationSummary,
|
||
AskUserQuestion: askUserQuestion,
|
||
FallbackUsed: fallbackUsed,
|
||
Confidence: deriveInternalConfidence(selected),
|
||
}, fallbackUsed
|
||
}
|
||
|
||
func buildFallbackResult(req SelectRequest, reason string) Result {
|
||
selected := pickFallbackCandidate(req)
|
||
action := inferAction(selected)
|
||
explanation := firstNonEmpty(selected.Summary, reason)
|
||
return Result{
|
||
Action: action,
|
||
SelectedCandidateID: selected.CandidateID,
|
||
Reason: strings.TrimSpace(reason),
|
||
ExplanationText: explanation,
|
||
NotificationSummary: explanation,
|
||
AskUserQuestion: fallbackAskUserQuestion(action, explanation),
|
||
FallbackUsed: true,
|
||
Confidence: deriveInternalConfidence(selected),
|
||
}
|
||
}
|
||
|
||
func pickFallbackCandidate(req SelectRequest) candidate.Candidate {
|
||
fallbackID := strings.TrimSpace(req.Observation.Decision.FallbackCandidateID)
|
||
if fallbackID != "" {
|
||
if selected, ok := findCandidate(req.Candidates, fallbackID); ok {
|
||
return selected
|
||
}
|
||
}
|
||
return req.Candidates[0]
|
||
}
|
||
|
||
func findCandidate(candidates []candidate.Candidate, id string) (candidate.Candidate, bool) {
|
||
id = strings.TrimSpace(id)
|
||
if id == "" {
|
||
return candidate.Candidate{}, false
|
||
}
|
||
for _, item := range candidates {
|
||
if strings.TrimSpace(item.CandidateID) == id {
|
||
return item, true
|
||
}
|
||
}
|
||
return candidate.Candidate{}, false
|
||
}
|
||
|
||
func normalizeAction(raw string) string {
|
||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||
case ActionSelectCandidate:
|
||
return ActionSelectCandidate
|
||
case ActionAskUser:
|
||
return ActionAskUser
|
||
case ActionNotifyOnly:
|
||
return ActionNotifyOnly
|
||
case ActionClose:
|
||
return ActionClose
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func inferAction(item candidate.Candidate) string {
|
||
switch item.CandidateType {
|
||
case candidate.TypeAskUser:
|
||
return ActionAskUser
|
||
case candidate.TypeNotifyOnly:
|
||
return ActionNotifyOnly
|
||
case candidate.TypeClose:
|
||
return ActionClose
|
||
default:
|
||
return ActionSelectCandidate
|
||
}
|
||
}
|
||
|
||
func isActionCompatible(action string, candidateType candidate.Type) bool {
|
||
switch candidateType {
|
||
case candidate.TypeAskUser:
|
||
return action == ActionAskUser
|
||
case candidate.TypeNotifyOnly:
|
||
return action == ActionNotifyOnly
|
||
case candidate.TypeClose:
|
||
return action == ActionClose
|
||
default:
|
||
return action == ActionSelectCandidate
|
||
}
|
||
}
|
||
|
||
func fallbackAskUserQuestion(action string, explanation string) string {
|
||
if action != ActionAskUser {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(explanation)
|
||
}
|
||
|
||
func deriveInternalConfidence(item candidate.Candidate) float64 {
|
||
if !item.Validation.Valid {
|
||
return 0.2
|
||
}
|
||
if item.Score <= 0 {
|
||
return 0.55
|
||
}
|
||
score := float64(item.Score) / 100
|
||
return math.Max(0.35, math.Min(0.95, score))
|
||
}
|
||
|
||
func firstNonEmpty(values ...string) string {
|
||
for _, value := range values {
|
||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||
return trimmed
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
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 (r Result) String() string {
|
||
return fmt.Sprintf("active_scheduler_selection(action=%s, selected=%s, fallback=%t)",
|
||
r.Action,
|
||
r.SelectedCandidateID,
|
||
r.FallbackUsed,
|
||
)
|
||
}
|