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 收口并同步接力状态
This commit is contained in:
61
backend/active_scheduler/selection/dto.go
Normal file
61
backend/active_scheduler/selection/dto.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package selection
|
||||
|
||||
import (
|
||||
"github.com/LoveLosita/smartflow/backend/active_scheduler/candidate"
|
||||
schedulercontext "github.com/LoveLosita/smartflow/backend/active_scheduler/context"
|
||||
"github.com/LoveLosita/smartflow/backend/active_scheduler/observe"
|
||||
)
|
||||
|
||||
const (
|
||||
ActionSelectCandidate = "select_candidate"
|
||||
ActionAskUser = "ask_user"
|
||||
ActionNotifyOnly = "notify_only"
|
||||
ActionClose = "close"
|
||||
)
|
||||
|
||||
// SelectRequest 是主动调度选择器的输入。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只承载已经由 dry-run 生成并校验过的上下文、观测结果和候选;
|
||||
// 2. 不包含任何模型实例,不负责 prompt 拼接;
|
||||
// 3. 由 graph runner 在 dry-run 之后传入,避免选择器直接回查数据库。
|
||||
type SelectRequest struct {
|
||||
ActiveContext *schedulercontext.ActiveScheduleContext `json:"-"`
|
||||
Observation observe.Result `json:"-"`
|
||||
Candidates []candidate.Candidate `json:"-"`
|
||||
}
|
||||
|
||||
// Result 是选择器的结构化输出。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只记录最终选中的候选与给用户看的解释摘要;
|
||||
// 2. 不包含正式日程写入结果,也不包含通知投递结果;
|
||||
// 3. FallbackUsed 只表示本次是否回退到了确定性兜底,不允许靠 selected_candidate_id 推断。
|
||||
type Result struct {
|
||||
Action string `json:"action"`
|
||||
SelectedCandidateID string `json:"selected_candidate_id,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
ExplanationText string `json:"explanation_text,omitempty"`
|
||||
NotificationSummary string `json:"notification_summary,omitempty"`
|
||||
AskUserQuestion string `json:"ask_user_question,omitempty"`
|
||||
FallbackUsed bool `json:"fallback_used,omitempty"`
|
||||
Confidence float64 `json:"confidence,omitempty"`
|
||||
}
|
||||
|
||||
// CandidateView 是暴露给 LLM 的最小候选视图。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只保留用于有限选择的基础信息和少量结构化维度;
|
||||
// 2. 不暴露 score / validation 这类内部实现细节;
|
||||
// 3. 不直接携带原始日程事实,避免模型看到过多上下文。
|
||||
type CandidateView struct {
|
||||
CandidateID string `json:"candidate_id"`
|
||||
CandidateType string `json:"candidate_type"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
BeforeSummary string `json:"before_summary"`
|
||||
AfterSummary string `json:"after_summary"`
|
||||
ChangeSummary string `json:"change_summary"`
|
||||
CapacityFit string `json:"capacity_fit"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
}
|
||||
232
backend/active_scheduler/selection/prompt.go
Normal file
232
backend/active_scheduler/selection/prompt.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package selection
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/active_scheduler/candidate"
|
||||
)
|
||||
|
||||
const selectionSystemPrompt = `
|
||||
你是 SmartFlow 主动调度的候选选择器。
|
||||
|
||||
你的职责很窄:
|
||||
1. 只在后端已经生成并校验过的候选里做有限选择;
|
||||
2. 只参考结构化事实、候选基础信息、capacity_fit 和 risk_level;
|
||||
3. 不要输出推理过程,不要输出 score,不要输出 confidence,不要编造新的候选;
|
||||
4. 只输出 JSON,不要输出 markdown,不要输出解释性正文。
|
||||
|
||||
允许的 action:
|
||||
- select_candidate
|
||||
- ask_user
|
||||
- notify_only
|
||||
- close
|
||||
|
||||
输出 JSON 结构:
|
||||
{
|
||||
"action": "select_candidate",
|
||||
"selected_candidate_id": "cand_xxx",
|
||||
"reason": "简短选择理由",
|
||||
"explanation_text": "给用户看的简短解释",
|
||||
"notification_summary": "通知里要显示的简短摘要",
|
||||
"ask_user_question": "需要追问时填写,否则留空"
|
||||
}
|
||||
|
||||
规则:
|
||||
1. selected_candidate_id 必须来自候选列表;如果 action=close,也要选候选列表里对应的 close 候选;
|
||||
2. 如果需要追问,优先选择 ask_user 候选,并把 ask_user_question 写清楚;
|
||||
3. 如果只需要提醒,不要编造正式日程变更;
|
||||
4. 如果候选里已经有明显更稳妥的项,优先选风险更低且 capacity_fit 更好的那个;
|
||||
5. 不要试图重排整段日程,第一版只在候选之间做有限裁决;
|
||||
6. 如果信息不足,就直接走 ask_user,不要硬猜。
|
||||
`
|
||||
|
||||
type selectionPromptInput struct {
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
Trigger selectionTriggerInput `json:"trigger"`
|
||||
DecisionHint selectionDecisionInput `json:"decision_hint"`
|
||||
Context selectionContextInput `json:"context"`
|
||||
Candidates []CandidateView `json:"candidates"`
|
||||
}
|
||||
|
||||
type selectionTriggerInput struct {
|
||||
TriggerID string `json:"trigger_id"`
|
||||
TriggerType string `json:"trigger_type"`
|
||||
Source string `json:"source"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID int `json:"target_id"`
|
||||
TargetTitle string `json:"target_title"`
|
||||
RequestedAt string `json:"requested_at"`
|
||||
TraceID string `json:"trace_id"`
|
||||
}
|
||||
|
||||
type selectionDecisionInput struct {
|
||||
Action string `json:"action"`
|
||||
PrimaryIssueCode string `json:"primary_issue_code"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
ShouldNotify bool `json:"should_notify"`
|
||||
ShouldWritePreview bool `json:"should_write_preview"`
|
||||
FallbackCandidateID string `json:"fallback_candidate_id,omitempty"`
|
||||
}
|
||||
|
||||
type selectionContextInput struct {
|
||||
WindowStart string `json:"window_start"`
|
||||
WindowEnd string `json:"window_end"`
|
||||
WindowReason string `json:"window_reason"`
|
||||
MissingInfo []string `json:"missing_info"`
|
||||
Warnings []string `json:"warnings"`
|
||||
TraceSteps []string `json:"trace_steps"`
|
||||
}
|
||||
|
||||
func buildSelectionPromptInput(req SelectRequest, now time.Time) selectionPromptInput {
|
||||
activeContext := req.ActiveContext
|
||||
decision := req.Observation.Decision
|
||||
input := selectionPromptInput{
|
||||
GeneratedAt: now.In(time.Local).Format(time.RFC3339),
|
||||
DecisionHint: selectionDecisionInput{
|
||||
Action: string(decision.Action),
|
||||
PrimaryIssueCode: string(decision.PrimaryIssueCode),
|
||||
ReasonCode: decision.ReasonCode,
|
||||
ShouldNotify: decision.ShouldNotify,
|
||||
ShouldWritePreview: decision.ShouldWritePreview,
|
||||
FallbackCandidateID: strings.TrimSpace(decision.FallbackCandidateID),
|
||||
},
|
||||
}
|
||||
if activeContext != nil {
|
||||
input.Trigger = selectionTriggerInput{
|
||||
TriggerID: activeContext.Trigger.TriggerID,
|
||||
TriggerType: string(activeContext.Trigger.TriggerType),
|
||||
Source: string(activeContext.Trigger.Source),
|
||||
TargetType: string(activeContext.Trigger.TargetType),
|
||||
TargetID: activeContext.Trigger.TargetID,
|
||||
TargetTitle: activeContext.Target.Title,
|
||||
RequestedAt: activeContext.Trigger.RequestedAt.In(time.Local).Format(time.RFC3339),
|
||||
TraceID: activeContext.Trace.TraceID,
|
||||
}
|
||||
input.Context = selectionContextInput{
|
||||
WindowStart: activeContext.Window.StartAt.In(time.Local).Format(time.RFC3339),
|
||||
WindowEnd: activeContext.Window.EndAt.In(time.Local).Format(time.RFC3339),
|
||||
WindowReason: activeContext.Window.WindowReason,
|
||||
MissingInfo: append([]string(nil), activeContext.DerivedFacts.MissingInfo...),
|
||||
Warnings: append([]string(nil), activeContext.Trace.Warnings...),
|
||||
TraceSteps: append([]string(nil), activeContext.Trace.BuildSteps...),
|
||||
}
|
||||
}
|
||||
|
||||
input.Candidates = make([]CandidateView, 0, len(req.Candidates))
|
||||
for _, item := range req.Candidates {
|
||||
input.Candidates = append(input.Candidates, buildCandidateView(req, item))
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func buildSelectionUserPrompt(input selectionPromptInput) (string, error) {
|
||||
raw, err := json.MarshalIndent(input, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("请基于下面的结构化事实,从候选中选一个最合适的结果。\n")
|
||||
sb.WriteString("输入:\n")
|
||||
sb.WriteString(string(raw))
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString("只输出 JSON。")
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func buildCandidateView(req SelectRequest, item candidate.Candidate) CandidateView {
|
||||
return CandidateView{
|
||||
CandidateID: item.CandidateID,
|
||||
CandidateType: string(item.CandidateType),
|
||||
Title: item.Title,
|
||||
Summary: item.Summary,
|
||||
BeforeSummary: item.BeforeSummary,
|
||||
AfterSummary: item.AfterSummary,
|
||||
ChangeSummary: buildChangeSummary(item),
|
||||
CapacityFit: deriveCapacityFit(req, item),
|
||||
RiskLevel: deriveRiskLevel(req, item),
|
||||
}
|
||||
}
|
||||
|
||||
func buildChangeSummary(item candidate.Candidate) string {
|
||||
if len(item.Changes) == 0 {
|
||||
return "无正式变更"
|
||||
}
|
||||
lines := make([]string, 0, len(item.Changes))
|
||||
for _, change := range item.Changes {
|
||||
lines = append(lines, summarizeChange(change))
|
||||
}
|
||||
return strings.Join(lines, ";")
|
||||
}
|
||||
|
||||
func summarizeChange(change candidate.ChangeItem) string {
|
||||
switch change.ChangeType {
|
||||
case candidate.ChangeTypeAdd:
|
||||
if change.ToSlot != nil {
|
||||
return fmt.Sprintf("新增到 第%d周 第%d天 第%d-%d节,持续%d节",
|
||||
change.ToSlot.Start.Week,
|
||||
change.ToSlot.Start.DayOfWeek,
|
||||
change.ToSlot.Start.Section,
|
||||
change.ToSlot.End.Section,
|
||||
change.DurationSections,
|
||||
)
|
||||
}
|
||||
return "新增一段日程"
|
||||
case candidate.ChangeTypeCreateMakeup:
|
||||
if change.ToSlot != nil {
|
||||
return fmt.Sprintf("为目标补做一段第%d周第%d天第%d-%d节的时间块",
|
||||
change.ToSlot.Start.Week,
|
||||
change.ToSlot.Start.DayOfWeek,
|
||||
change.ToSlot.Start.Section,
|
||||
change.ToSlot.End.Section,
|
||||
)
|
||||
}
|
||||
return "新增补做块"
|
||||
case candidate.ChangeTypeAskUser:
|
||||
return "需要用户补充信息"
|
||||
default:
|
||||
return "不修改正式日程"
|
||||
}
|
||||
}
|
||||
|
||||
func deriveCapacityFit(req SelectRequest, item candidate.Candidate) string {
|
||||
switch item.CandidateType {
|
||||
case candidate.TypeAskUser, candidate.TypeNotifyOnly, candidate.TypeClose:
|
||||
return "not_applicable"
|
||||
}
|
||||
|
||||
if !item.Validation.Valid {
|
||||
return "insufficient"
|
||||
}
|
||||
|
||||
gap := req.Observation.Metrics.Window.CapacityGap
|
||||
switch {
|
||||
case gap > 0:
|
||||
return "insufficient"
|
||||
case gap == 0:
|
||||
return "tight"
|
||||
default:
|
||||
return "fit"
|
||||
}
|
||||
}
|
||||
|
||||
func deriveRiskLevel(req SelectRequest, item candidate.Candidate) string {
|
||||
if !item.Validation.Valid {
|
||||
return "high"
|
||||
}
|
||||
switch item.CandidateType {
|
||||
case candidate.TypeCreateMakeup:
|
||||
return "medium"
|
||||
case candidate.TypeAddTaskPoolToSchedule:
|
||||
if req.Observation.Metrics.Window.CapacityGap == 0 {
|
||||
return "medium"
|
||||
}
|
||||
return "low"
|
||||
case candidate.TypeAskUser, candidate.TypeNotifyOnly, candidate.TypeClose:
|
||||
return "low"
|
||||
default:
|
||||
return "low"
|
||||
}
|
||||
}
|
||||
303
backend/active_scheduler/selection/service.go
Normal file
303
backend/active_scheduler/selection/service.go
Normal file
@@ -0,0 +1,303 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user