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:
Losita
2026-05-01 20:48:32 +08:00
parent 0a014f7472
commit a3eaa9b2c2
42 changed files with 4377 additions and 357 deletions

View File

@@ -0,0 +1,198 @@
package graph
import (
"context"
"errors"
"fmt"
"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"
"github.com/LoveLosita/smartflow/backend/active_scheduler/selection"
"github.com/LoveLosita/smartflow/backend/active_scheduler/trigger"
"github.com/cloudwego/eino/compose"
)
const GraphName = "active_schedule_graph"
const (
NodeDryRun = "dry_run"
NodeSelect = "select"
)
// DryRunData 承载 graph 中 dry-run 节点产出的只读结果。
//
// 职责边界:
// 1. 只装载构造 preview / 选择器所需的 dry-run 结果;
// 2. 不包含落库状态,也不包含通知副作用;
// 3. 由 graph runner 负责串联后续选择步骤。
type DryRunData struct {
Context *schedulercontext.ActiveScheduleContext
Observation observe.Result
Candidates []candidate.Candidate
}
// DryRunFunc 描述 graph 依赖的 dry-run 执行入口。
//
// 职责边界:
// 1. 只负责把 trigger 变成 dry-run 结果;
// 2. 不负责 selection / preview / notification
// 3. 由 service 层用现有 DryRunService 做适配注入。
type DryRunFunc func(ctx context.Context, trig trigger.ActiveScheduleTrigger) (*DryRunData, error)
// Selector 描述 graph 依赖的候选选择器。
//
// 职责边界:
// 1. 只负责在后端候选里做有限选择与解释生成;
// 2. 不负责构造 dry-run 结果;
// 3. 不负责 preview 落库与通知投递。
type Selector interface {
Select(ctx context.Context, req selection.SelectRequest) (selection.Result, error)
}
// RunResult 是 graph 的最终输出。
//
// 职责边界:
// 1. 只回传 dry-run 与选择结果;
// 2. 不包含 preview / notification 的持久化结果;
// 3. 上层 service 仍负责事务、落库和 outbox。
type RunResult struct {
DryRunData *DryRunData
SelectionResult selection.Result
}
type runState struct {
Trigger trigger.ActiveScheduleTrigger
DryRunData *DryRunData
SelectionResult selection.Result
}
// Runner 把 dry-run 与受限选择串成一条可复用的 graph。
type Runner struct {
dryRun DryRunFunc
selector Selector
}
// NewRunner 创建主动调度 graph runner。
func NewRunner(dryRun DryRunFunc, selector Selector) (*Runner, error) {
if dryRun == nil {
return nil, errors.New("active scheduler dry-run 不能为空")
}
if selector == nil {
return nil, errors.New("active scheduler selector 不能为空")
}
return &Runner{dryRun: dryRun, selector: selector}, nil
}
// Run 执行 dry-run -> select 的 graph。
//
// 步骤化说明:
// 1. 先跑 dry-run拿到上下文、观测结果与候选
// 2. 再交给受限选择器做选中与解释生成;
// 3. 任一步失败都直接返回,避免写入半截 preview
// 4. 上层 service 再决定是否写 preview / 发通知。
func (r *Runner) Run(ctx context.Context, trig trigger.ActiveScheduleTrigger) (*RunResult, error) {
if r == nil || r.dryRun == nil || r.selector == nil {
return nil, errors.New("active scheduler graph runner 未初始化")
}
state := &runState{Trigger: trig}
g := compose.NewGraph[*runState, *runState]()
if err := g.AddLambdaNode(NodeDryRun, compose.InvokableLambda(r.dryRunNode())); err != nil {
return nil, err
}
if err := g.AddLambdaNode(NodeSelect, compose.InvokableLambda(r.selectNode())); err != nil {
return nil, err
}
if err := g.AddEdge(compose.START, NodeDryRun); err != nil {
return nil, err
}
if err := g.AddEdge(NodeDryRun, NodeSelect); err != nil {
return nil, err
}
if err := g.AddEdge(NodeSelect, compose.END); err != nil {
return nil, err
}
runnable, err := g.Compile(ctx,
compose.WithGraphName(GraphName),
compose.WithMaxRunSteps(8),
compose.WithNodeTriggerMode(compose.AnyPredecessor),
)
if err != nil {
return nil, err
}
out, err := runnable.Invoke(ctx, state)
if err != nil {
return nil, err
}
if out == nil {
return nil, errors.New("active scheduler graph 返回空状态")
}
return &RunResult{
DryRunData: out.DryRunData,
SelectionResult: out.SelectionResult,
}, nil
}
func (r *Runner) dryRunNode() func(context.Context, *runState) (*runState, error) {
return func(ctx context.Context, state *runState) (*runState, error) {
if state == nil {
return nil, errors.New("active scheduler graph state 不能为空")
}
if r == nil || r.dryRun == nil {
return nil, errors.New("active scheduler dry-run 不能为空")
}
// 1. 先跑 dry-run汇集上下文、观测结果和候选避免 selection 直接依赖数据库。
// 2. dry-run 出错时直接返回,不进入后续选择。
result, err := r.dryRun(ctx, state.Trigger)
if err != nil {
return nil, err
}
state.DryRunData = result
return state, nil
}
}
func (r *Runner) selectNode() func(context.Context, *runState) (*runState, error) {
return func(ctx context.Context, state *runState) (*runState, error) {
if state == nil {
return nil, errors.New("active scheduler graph state 不能为空")
}
if state.DryRunData == nil {
return nil, errors.New("active scheduler graph 缺少 dry-run 结果")
}
if r == nil || r.selector == nil {
return nil, errors.New("active scheduler selector 不能为空")
}
// 1. 没有候选时,不强行调用选择器,直接返回空选择结果,交给上层决定是否继续写 preview。
// 2. 有候选时再做受限 LLM 选择,确保模型只看后端生成的候选视图。
if len(state.DryRunData.Candidates) == 0 {
state.SelectionResult = selection.Result{}
return state, nil
}
result, err := r.selector.Select(ctx, selection.SelectRequest{
ActiveContext: state.DryRunData.Context,
Observation: state.DryRunData.Observation,
Candidates: state.DryRunData.Candidates,
})
if err != nil {
return nil, err
}
state.SelectionResult = result
return state, nil
}
}
func (r *Runner) String() string {
if r == nil {
return "active_scheduler_graph(nil)"
}
return fmt.Sprintf("active_scheduler_graph(dry_run=%t, selector=%t)", r.dryRun != nil, r.selector != nil)
}

View File

@@ -174,7 +174,7 @@ func entryFromEvent(event ports.ScheduleEventFact) SchedulePreviewEntry {
return entry
}
func riskDTO(selected candidate.Candidate, observation observe.Result, changes []ActiveScheduleChangeItem) RiskDTO {
func riskDTO(selected candidate.Candidate, observation observe.Result, changes []ActiveScheduleChangeItem, fallbackUsed bool) RiskDTO {
affectedIDs := make([]int, 0)
seen := make(map[int]bool)
for _, change := range changes {
@@ -198,7 +198,7 @@ func riskDTO(selected candidate.Candidate, observation observe.Result, changes [
RiskMetrics: observation.Metrics.Risk,
AffectedIDs: affectedIDs,
RequiresLLM: observation.Decision.LLMSelectionRequired,
FallbackUsed: observation.Decision.FallbackCandidateID == selected.CandidateID,
FallbackUsed: fallbackUsed,
}
}

View File

@@ -22,10 +22,12 @@ type CreatePreviewRequest struct {
Candidates []candidate.Candidate `json:"-"`
PreviewID string `json:"preview_id,omitempty"`
TriggerID string `json:"trigger_id,omitempty"`
SelectedCandidateID string `json:"selected_candidate_id,omitempty"`
BaseVersion string `json:"base_version,omitempty"`
GeneratedAt time.Time `json:"generated_at,omitempty"`
ExplanationText string `json:"explanation_text,omitempty"`
NotificationSummary string `json:"notification_summary,omitempty"`
FallbackUsed bool `json:"fallback_used,omitempty"`
}
// CreatePreviewResponse 是写入 preview 后可直接返回给 API 的响应 DTO。

View File

@@ -65,9 +65,10 @@ func (s *Service) SetClock(clock func() time.Time) {
// CreatePreview 把 dry-run 结果保存为 ready preview。
//
// 职责边界:
// 1. 只消费已经完成的 dry-run 结果,不重新读取任务/日程事实;
// 2. MVP 没有 LLM 选择器,固定使用后端排序后的 top1 candidate 作为 selected_candidate
// 3. 写库后只返回详情 DTO不发布通知、不正式应用候选、不回写 trigger。
// 1. 只消费已经完成的 dry-run 结果,不重新读取任务/日程事实;
// 2. 优先吃上层 selection 结果中的 selected_candidate_id / explanation / notification 摘要
// 若上层未显式传入,则为了兼容旧链路继续回退到 top1 candidate
// 3. 写库后只返回详情 DTO不发布通知、不正式应用候选、不回写 trigger。
func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) (*CreatePreviewResponse, error) {
if s == nil || s.repo == nil {
return nil, fmt.Errorf("%w: preview service 未初始化", ErrInvalidPreviewRequest)
@@ -97,9 +98,15 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) (
previewID = "asp_" + uuid.NewString()
}
// 1. 先构造所有展示快照,再写库;任何 JSON 转换失败都提前返回,避免落入半结构化记录。
selected := req.Candidates[0]
snapshot := buildSnapshot(activeContext, req.Observation, req.Candidates, selected)
// 1. 先解析选中的候选,再构造展示快照;任何 JSON 转换失败都提前返回,避免落入半结构化记录。
// 1.1 若上层已经给出 selected_candidate_id就严格按该候选落库避免 preview 与选择结果不一致。
// 1.2 若未给出,则继续沿用后端候选顺序的第一条,保持旧流程兼容。
// 1.3 若指定 ID 不在候选列表中,直接返回错误,避免写入一份错位的 preview。
selected, err := pickSelectedCandidate(req.Candidates, req.SelectedCandidateID)
if err != nil {
return nil, err
}
snapshot := buildSnapshot(activeContext, req.Observation, req.Candidates, selected, req.FallbackUsed)
baseVersion := strings.TrimSpace(req.BaseVersion)
if baseVersion == "" {
baseVersion = buildBaseVersion(activeContext, snapshot.changes)
@@ -170,6 +177,24 @@ func (s *Service) now() time.Time {
return s.clock()
}
func pickSelectedCandidate(candidates []candidate.Candidate, selectedCandidateID string) (candidate.Candidate, error) {
if len(candidates) == 0 {
return candidate.Candidate{}, fmt.Errorf("%w: dry-run 链路未生成可保存候选", ErrInvalidPreviewRequest)
}
selectedCandidateID = strings.TrimSpace(selectedCandidateID)
if selectedCandidateID == "" {
return candidates[0], nil
}
for _, item := range candidates {
if strings.TrimSpace(item.CandidateID) == selectedCandidateID {
return item, nil
}
}
return candidate.Candidate{}, fmt.Errorf("%w: selected_candidate_id 不在候选列表中", ErrInvalidPreviewRequest)
}
func buildPreviewModel(
previewID string,
triggerID string,
@@ -256,6 +281,7 @@ func buildSnapshot(
observation observe.Result,
candidates []candidate.Candidate,
selected candidate.Candidate,
fallbackUsed bool,
) rawPreviewSnapshot {
selectedDTO := candidateDTO(selected)
candidateDTOs := make([]CandidateDTO, 0, len(candidates))
@@ -276,6 +302,6 @@ func buildSnapshot(
before: before,
changes: changes,
after: after,
risk: riskDTO(selected, observation, changes),
risk: riskDTO(selected, observation, changes, fallbackUsed),
}
}

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

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

View 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,
)
}

View File

@@ -0,0 +1,31 @@
package service
import (
"context"
activegraph "github.com/LoveLosita/smartflow/backend/active_scheduler/graph"
"github.com/LoveLosita/smartflow/backend/active_scheduler/trigger"
)
// AsGraphDryRunFunc 把现有 dry-run service 适配成 graph runner 可用的入口。
//
// 职责边界:
// 1. 只做 service.Result -> graph.DryRunData 的轻量转换;
// 2. 不改写 dry-run 行为,不引入额外候选逻辑;
// 3. 让 graph runner 可以复用现有 BuildContext -> Observe -> GenerateCandidates 链路。
func (s *DryRunService) AsGraphDryRunFunc() activegraph.DryRunFunc {
if s == nil {
return nil
}
return func(ctx context.Context, trig trigger.ActiveScheduleTrigger) (*activegraph.DryRunData, error) {
result, err := s.DryRun(ctx, trig)
if err != nil {
return nil, err
}
return &activegraph.DryRunData{
Context: result.Context,
Observation: result.Observation,
Candidates: result.Candidates,
}, nil
}
}

View File

@@ -0,0 +1,302 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
activepreview "github.com/LoveLosita/smartflow/backend/active_scheduler/preview"
"github.com/LoveLosita/smartflow/backend/active_scheduler/selection"
"github.com/LoveLosita/smartflow/backend/dao"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/google/uuid"
"gorm.io/gorm"
)
var (
activeScheduleConversationNamespace = uuid.NewSHA1(uuid.NameSpaceURL, []byte("smartflow:active_schedule:conversation"))
activeScheduleSessionNamespace = uuid.NewSHA1(uuid.NameSpaceURL, []byte("smartflow:active_schedule:session"))
)
// WithActiveScheduleSessionBridge 注入主动调度 session 预创建所需的 DAO。
//
// 职责边界:
// 1. 只把 trigger -> notification 前的会话桥接能力接入 workflow
// 2. 不改变 dry-run / preview / notification 的主状态机;
// 3. 为空时保留旧能力,便于局部测试与迁移期回退。
func WithActiveScheduleSessionBridge(agentDAO *dao.AgentDAO, sessionDAO *dao.ActiveScheduleSessionDAO) TriggerWorkflowOption {
return func(s *TriggerWorkflowService) {
if s == nil {
return
}
s.agentDAO = agentDAO
s.sessionDAO = sessionDAO
}
}
// bootstrapActiveScheduleConversationInTx 负责在 notification 发出前预建会话与首屏内容。
//
// 步骤化说明:
// 1. 先生成确定性的 conversation/session ID保证 trigger 重试时不会拆成多条会话;
// 2. 再在同一事务里创建或复用 agent_chats 和 active_schedule_sessions
// 3. 如果是首次落库,则顺手补一条 assistant_text必要时再补一张主动调度卡片
// 4. 任一步失败都直接返回 error让上层事务整体回滚避免“通知已发但会话底稿没落”。
func (s *TriggerWorkflowService) bootstrapActiveScheduleConversationInTx(
ctx context.Context,
tx *gorm.DB,
triggerRow model.ActiveScheduleTrigger,
previewDetail activepreview.ActiveSchedulePreviewDetail,
selectionResult selection.Result,
now time.Time,
) error {
if s == nil {
return errors.New("主动调度会话桥未初始化")
}
if s.agentDAO == nil || s.sessionDAO == nil {
return nil
}
if tx == nil {
return errors.New("gorm tx 不能为空")
}
if triggerRow.ID == "" {
return errors.New("trigger_id 不能为空")
}
conversationID := buildActiveScheduleConversationID(triggerRow.ID)
sessionID := buildActiveScheduleSessionID(triggerRow.ID)
txAgentDAO := s.agentDAO.WithTx(tx)
txSessionDAO := s.sessionDAO.WithTx(tx)
if err := ensureAgentConversationExists(ctx, txAgentDAO, triggerRow.UserID, conversationID); err != nil {
return err
}
baseSeq, err := txAgentDAO.GetConversationTimelineMaxSeq(ctx, triggerRow.UserID, conversationID)
if err != nil {
return err
}
// 1. 只有首次创建会话时才写首屏消息,避免同一 trigger 的重试把时间线重复刷一遍。
// 2. 若 timeline 已存在,说明这段主动调度会话已经被成功预热过,直接复用现成内容即可。
if baseSeq == 0 {
assistantText := resolveInitialActiveScheduleAssistantText(selectionResult, previewDetail)
if assistantText != "" {
if err := txAgentDAO.SaveChatHistoryInTx(ctx, triggerRow.UserID, conversationID, "assistant", assistantText, "", 0, 0); err != nil {
return err
}
if err := saveActiveScheduleTimelineEvent(ctx, txAgentDAO, triggerRow.UserID, conversationID, baseSeq+1, model.AgentTimelineKindAssistantText, "assistant", assistantText, nil); err != nil {
return err
}
baseSeq++
}
if shouldSeedActiveSchedulePreviewCard(selectionResult) {
cardPayload, err := buildActiveScheduleBusinessCardPayload(previewDetail)
if err != nil {
return err
}
if err := saveActiveScheduleTimelineEvent(ctx, txAgentDAO, triggerRow.UserID, conversationID, baseSeq+1, model.AgentTimelineKindBusinessCard, "assistant", assistantText, cardPayload); err != nil {
return err
}
}
}
sessionSnapshot := &model.ActiveScheduleSessionSnapshot{
SessionID: sessionID,
UserID: triggerRow.UserID,
ConversationID: conversationID,
TriggerID: triggerRow.ID,
CurrentPreviewID: strings.TrimSpace(previewDetail.PreviewID),
Status: resolveInitialActiveScheduleSessionStatus(selectionResult),
State: buildInitialActiveScheduleSessionState(selectionResult, previewDetail),
CreatedAt: now,
UpdatedAt: now,
}
return txSessionDAO.UpsertActiveScheduleSession(ctx, sessionSnapshot)
}
func buildActiveScheduleConversationID(triggerID string) string {
normalized := strings.TrimSpace(triggerID)
if normalized == "" {
return uuid.NewString()
}
return uuid.NewSHA1(activeScheduleConversationNamespace, []byte(normalized)).String()
}
func buildActiveScheduleSessionID(triggerID string) string {
normalized := strings.TrimSpace(triggerID)
if normalized == "" {
return uuid.NewString()
}
return uuid.NewSHA1(activeScheduleSessionNamespace, []byte(normalized)).String()
}
func ensureAgentConversationExists(ctx context.Context, agentDAO *dao.AgentDAO, userID int, conversationID string) error {
if agentDAO == nil {
return errors.New("agent dao 不能为空")
}
if userID <= 0 {
return fmt.Errorf("invalid user_id: %d", userID)
}
normalizedConversationID := strings.TrimSpace(conversationID)
if normalizedConversationID == "" {
return errors.New("conversation_id 不能为空")
}
exists, err := agentDAO.IfChatExists(ctx, userID, normalizedConversationID)
if err != nil {
return err
}
if exists {
return nil
}
_, err = agentDAO.CreateNewChat(userID, normalizedConversationID)
return err
}
func resolveInitialActiveScheduleAssistantText(selectionResult selection.Result, previewDetail activepreview.ActiveSchedulePreviewDetail) string {
switch selectionResult.Action {
case selection.ActionAskUser:
return firstNonEmptyString(
selectionResult.AskUserQuestion,
selectionResult.ExplanationText,
previewDetail.Explanation,
previewDetail.Notification,
"请先补充主动调度需要的关键信息。",
)
default:
return firstNonEmptyString(
selectionResult.ExplanationText,
selectionResult.NotificationSummary,
previewDetail.Notification,
previewDetail.Explanation,
"主动调度建议已更新。",
)
}
}
func shouldSeedActiveSchedulePreviewCard(selectionResult selection.Result) bool {
return selectionResult.Action == selection.ActionSelectCandidate
}
func resolveInitialActiveScheduleSessionStatus(selectionResult selection.Result) string {
switch selectionResult.Action {
case selection.ActionAskUser:
return model.ActiveScheduleSessionStatusWaitingUserReply
case selection.ActionSelectCandidate:
return model.ActiveScheduleSessionStatusReadyPreview
default:
return model.ActiveScheduleSessionStatusIgnored
}
}
func buildInitialActiveScheduleSessionState(
selectionResult selection.Result,
previewDetail activepreview.ActiveSchedulePreviewDetail,
) model.ActiveScheduleSessionState {
state := model.ActiveScheduleSessionState{
LastCandidateID: strings.TrimSpace(selectionResult.SelectedCandidateID),
MissingInfo: cloneStringSlice(previewDetail.ContextSummary.MissingInfo),
}
if !previewDetail.ExpiresAt.IsZero() {
expiresAt := previewDetail.ExpiresAt
state.ExpiresAt = &expiresAt
}
switch selectionResult.Action {
case selection.ActionAskUser:
state.PendingQuestion = firstNonEmptyString(
selectionResult.AskUserQuestion,
selectionResult.ExplanationText,
)
case selection.ActionSelectCandidate:
state.PendingQuestion = ""
state.MissingInfo = nil
state.FailedReason = ""
default:
state.PendingQuestion = ""
state.MissingInfo = nil
state.ExpiresAt = nil
}
return state
}
func buildActiveScheduleBusinessCardPayload(detail activepreview.ActiveSchedulePreviewDetail) (map[string]any, error) {
raw, err := json.Marshal(map[string]any{
"business_card": map[string]any{
"card_type": "active_schedule_preview",
"title": "SmartFlow 日程调整建议",
"summary": firstNonEmptyString(detail.Notification, detail.Explanation, detail.SelectedCandidate.Summary),
"data": detail,
},
})
if err != nil {
return nil, err
}
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
return nil, err
}
return payload, nil
}
func saveActiveScheduleTimelineEvent(
ctx context.Context,
agentDAO *dao.AgentDAO,
userID int,
conversationID string,
seq int64,
kind string,
role string,
content string,
payload map[string]any,
) error {
if agentDAO == nil {
return errors.New("agent dao 不能为空")
}
normalizedConversationID := strings.TrimSpace(conversationID)
if userID <= 0 || normalizedConversationID == "" {
return errors.New("时间线事件主键不合法")
}
payloadJSON := ""
if len(payload) > 0 {
raw, err := json.Marshal(payload)
if err != nil {
return err
}
payloadJSON = string(raw)
}
_, _, err := agentDAO.SaveConversationTimelineEvent(ctx, model.ChatTimelinePersistPayload{
UserID: userID,
ConversationID: normalizedConversationID,
Seq: seq,
Kind: kind,
Role: role,
Content: content,
PayloadJSON: payloadJSON,
TokensConsumed: 0,
})
return err
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func cloneStringSlice(values []string) []string {
if len(values) == 0 {
return nil
}
cloned := make([]string, len(values))
copy(cloned, values)
return cloned
}

View File

@@ -118,6 +118,7 @@ func BuildFeishuRequestedPayload(
requestedAt time.Time,
) sharedevents.FeishuNotificationRequestedPayload {
summary := strings.TrimSpace(notificationSummary)
targetURL := fmt.Sprintf("/assistant/%s", buildActiveScheduleConversationID(triggerRow.ID))
return sharedevents.FeishuNotificationRequestedPayload{
UserID: triggerRow.UserID,
TriggerID: triggerRow.ID,
@@ -126,9 +127,9 @@ func BuildFeishuRequestedPayload(
TargetType: triggerRow.TargetType,
TargetID: triggerRow.TargetID,
DedupeKey: BuildNotificationDedupeKey(triggerRow.UserID, triggerRow.TriggerType, triggerRow.RequestedAt),
TargetURL: fmt.Sprintf("/schedule-adjust/%s", strings.TrimSpace(previewID)),
TargetURL: targetURL,
SummaryText: summary,
FallbackText: buildNotificationFallbackText(summary, strings.TrimSpace(previewID)),
FallbackText: buildNotificationFallbackText(summary, targetURL),
TraceID: triggerRow.TraceID,
RequestedAt: requestedAt,
}
@@ -201,8 +202,8 @@ func normalizeKafkaConfig(cfg kafkabus.Config) kafkabus.Config {
return cfg
}
func buildNotificationFallbackText(summary string, previewID string) string {
link := fmt.Sprintf("/schedule-adjust/%s", previewID)
func buildNotificationFallbackText(summary string, targetURL string) string {
link := strings.TrimSpace(targetURL)
if summary == "" {
return "你有一条新的日程调整建议,请查看:" + link
}

View File

@@ -7,6 +7,7 @@ import (
"strings"
"time"
activegraph "github.com/LoveLosita/smartflow/backend/active_scheduler/graph"
activepreview "github.com/LoveLosita/smartflow/backend/active_scheduler/preview"
"github.com/LoveLosita/smartflow/backend/active_scheduler/trigger"
"github.com/LoveLosita/smartflow/backend/dao"
@@ -28,38 +29,57 @@ const (
//
// 职责边界:
// 1. 只推进主动调度 trigger 的后台状态机,不负责启动 outbox worker
// 2. dry-run 与 preview 复用现有 service不再单独实现第二套候选生成逻辑;
// 2. dry-run 与选择器都复用 active_scheduler 独立模块,不再往 newAgent 里塞主动调度逻辑;
// 3. notification 只发布 requested 事件,不直接接真实飞书 provider。
type TriggerWorkflowService struct {
activeDAO *dao.ActiveScheduleDAO
dryRun *DryRunService
outbox *outboxinfra.Repository
kafkaCfg kafkabus.Config
clock func() time.Time
activeDAO *dao.ActiveScheduleDAO
graphRunner *activegraph.Runner
outbox *outboxinfra.Repository
kafkaCfg kafkabus.Config
agentDAO *dao.AgentDAO
sessionDAO *dao.ActiveScheduleSessionDAO
clock func() time.Time
}
func NewTriggerWorkflowService(
activeDAO *dao.ActiveScheduleDAO,
dryRun *DryRunService,
graphRunner *activegraph.Runner,
outboxRepo *outboxinfra.Repository,
kafkaCfg kafkabus.Config,
) (*TriggerWorkflowService, error) {
return NewTriggerWorkflowServiceWithOptions(activeDAO, graphRunner, outboxRepo, kafkaCfg)
}
// NewTriggerWorkflowServiceWithOptions 创建主动调度 trigger 编排服务,并允许注入迁移期可选能力。
func NewTriggerWorkflowServiceWithOptions(
activeDAO *dao.ActiveScheduleDAO,
graphRunner *activegraph.Runner,
outboxRepo *outboxinfra.Repository,
kafkaCfg kafkabus.Config,
opts ...TriggerWorkflowOption,
) (*TriggerWorkflowService, error) {
if activeDAO == nil {
return nil, errors.New("active schedule dao 不能为空")
}
if dryRun == nil {
return nil, errors.New("dry-run service 不能为空")
if graphRunner == nil {
return nil, errors.New("active scheduler graph runner 不能为空")
}
if outboxRepo == nil {
return nil, errors.New("outbox repository 不能为空")
}
return &TriggerWorkflowService{
activeDAO: activeDAO,
dryRun: dryRun,
outbox: outboxRepo,
kafkaCfg: kafkaCfg,
clock: time.Now,
}, nil
svc := &TriggerWorkflowService{
activeDAO: activeDAO,
graphRunner: graphRunner,
outbox: outboxRepo,
kafkaCfg: kafkaCfg,
clock: time.Now,
}
for _, opt := range opts {
if opt != nil {
opt(svc)
}
}
return svc, nil
}
func (s *TriggerWorkflowService) SetClock(clock func() time.Time) {
@@ -68,12 +88,15 @@ func (s *TriggerWorkflowService) SetClock(clock func() time.Time) {
}
}
// TriggerWorkflowOption 是 trigger 编排服务的可选注入项。
type TriggerWorkflowOption func(*TriggerWorkflowService)
// ProcessTriggeredInTx 在 outbox 消费事务内推进 trigger 主链路。
//
// 步骤化说明:
// 1. 先锁 trigger 行,确保同一 trigger 在并发 worker 下只能由一个事务推进;
// 2. 再把状态切到 processing避免排障时看不出消息已经被消费
// 3. 复用 dry-run + preview service 生成预览;若发现已有 preview则直接复用避免重复写库
// 3. 复用 active scheduler graph 跑 dry-run + 受限选择;若发现已有 preview则直接复用避免重复写库
// 4. preview 成功后回写 trigger 状态,并在同一事务里补发 notification.requested outbox
// 5. 任一步失败都返回 error由外层 handler 负责记录 failed 状态并触发 outbox retry。
func (s *TriggerWorkflowService) ProcessTriggeredInTx(
@@ -81,7 +104,7 @@ func (s *TriggerWorkflowService) ProcessTriggeredInTx(
tx *gorm.DB,
payload sharedevents.ActiveScheduleTriggeredPayload,
) error {
if s == nil || s.activeDAO == nil || s.dryRun == nil || s.outbox == nil {
if s == nil || s.activeDAO == nil || s.graphRunner == nil || s.outbox == nil {
return errors.New("trigger workflow service 未初始化")
}
if tx == nil {
@@ -125,14 +148,18 @@ func (s *TriggerWorkflowService) ProcessTriggeredInTx(
}
domainTrigger := buildDomainTriggerFromModel(*triggerRow, payload)
dryRunResult, err := s.dryRun.DryRun(ctx, domainTrigger)
graphResult, err := s.graphRunner.Run(ctx, domainTrigger)
if err != nil {
return err
}
if len(dryRunResult.Candidates) == 0 {
if graphResult == nil || graphResult.DryRunData == nil {
return errors.New("active scheduler graph 返回空结果")
}
dryRunData := graphResult.DryRunData
if len(dryRunData.Candidates) == 0 {
return s.markClosedWithoutPreview(ctx, txDAO, triggerRow.ID, now)
}
if !dryRunResult.Observation.Decision.ShouldNotify && !dryRunResult.Observation.Decision.ShouldWritePreview {
if !dryRunData.Observation.Decision.ShouldNotify && !dryRunData.Observation.Decision.ShouldWritePreview {
return s.markClosedWithoutPreview(ctx, txDAO, triggerRow.ID, now)
}
@@ -141,11 +168,15 @@ func (s *TriggerWorkflowService) ProcessTriggeredInTx(
return err
}
previewResp, err := previewService.CreatePreview(ctx, activepreview.CreatePreviewRequest{
ActiveContext: dryRunResult.Context,
Observation: dryRunResult.Observation,
Candidates: dryRunResult.Candidates,
TriggerID: triggerRow.ID,
GeneratedAt: now,
ActiveContext: dryRunData.Context,
Observation: dryRunData.Observation,
Candidates: dryRunData.Candidates,
TriggerID: triggerRow.ID,
GeneratedAt: now,
SelectedCandidateID: graphResult.SelectionResult.SelectedCandidateID,
ExplanationText: graphResult.SelectionResult.ExplanationText,
NotificationSummary: graphResult.SelectionResult.NotificationSummary,
FallbackUsed: graphResult.SelectionResult.FallbackUsed,
})
if err != nil {
return err
@@ -162,10 +193,16 @@ func (s *TriggerWorkflowService) ProcessTriggeredInTx(
return err
}
if !dryRunResult.Observation.Decision.ShouldNotify {
if !dryRunData.Observation.Decision.ShouldNotify {
return nil
}
// 1. 离线通知发出前,先把用户点击后要进入的助手会话和主动调度 session 预热好。
// 2. 这一步和 preview / notification outbox 在同一事务内提交,避免出现“飞书已送达但会话空白”的断裂状态。
if err := s.bootstrapActiveScheduleConversationInTx(ctx, tx, *triggerRow, previewResp.Detail, graphResult.SelectionResult, now); err != nil {
return err
}
notificationPayload := BuildFeishuRequestedPayload(
*triggerRow,
previewID,