Files
smartmate/backend/active_scheduler/selection/service.go
Losita a3eaa9b2c2 Version: 0.9.61.dev.260501
后端:
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 收口并同步接力状态
2026-05-01 20:48:32 +08:00

304 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
)
}