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:
198
backend/active_scheduler/graph/runner.go
Normal file
198
backend/active_scheduler/graph/runner.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -174,7 +174,7 @@ func entryFromEvent(event ports.ScheduleEventFact) SchedulePreviewEntry {
|
|||||||
return entry
|
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)
|
affectedIDs := make([]int, 0)
|
||||||
seen := make(map[int]bool)
|
seen := make(map[int]bool)
|
||||||
for _, change := range changes {
|
for _, change := range changes {
|
||||||
@@ -198,7 +198,7 @@ func riskDTO(selected candidate.Candidate, observation observe.Result, changes [
|
|||||||
RiskMetrics: observation.Metrics.Risk,
|
RiskMetrics: observation.Metrics.Risk,
|
||||||
AffectedIDs: affectedIDs,
|
AffectedIDs: affectedIDs,
|
||||||
RequiresLLM: observation.Decision.LLMSelectionRequired,
|
RequiresLLM: observation.Decision.LLMSelectionRequired,
|
||||||
FallbackUsed: observation.Decision.FallbackCandidateID == selected.CandidateID,
|
FallbackUsed: fallbackUsed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,10 +22,12 @@ type CreatePreviewRequest struct {
|
|||||||
Candidates []candidate.Candidate `json:"-"`
|
Candidates []candidate.Candidate `json:"-"`
|
||||||
PreviewID string `json:"preview_id,omitempty"`
|
PreviewID string `json:"preview_id,omitempty"`
|
||||||
TriggerID string `json:"trigger_id,omitempty"`
|
TriggerID string `json:"trigger_id,omitempty"`
|
||||||
|
SelectedCandidateID string `json:"selected_candidate_id,omitempty"`
|
||||||
BaseVersion string `json:"base_version,omitempty"`
|
BaseVersion string `json:"base_version,omitempty"`
|
||||||
GeneratedAt time.Time `json:"generated_at,omitempty"`
|
GeneratedAt time.Time `json:"generated_at,omitempty"`
|
||||||
ExplanationText string `json:"explanation_text,omitempty"`
|
ExplanationText string `json:"explanation_text,omitempty"`
|
||||||
NotificationSummary string `json:"notification_summary,omitempty"`
|
NotificationSummary string `json:"notification_summary,omitempty"`
|
||||||
|
FallbackUsed bool `json:"fallback_used,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePreviewResponse 是写入 preview 后可直接返回给 API 的响应 DTO。
|
// CreatePreviewResponse 是写入 preview 后可直接返回给 API 的响应 DTO。
|
||||||
|
|||||||
@@ -65,9 +65,10 @@ func (s *Service) SetClock(clock func() time.Time) {
|
|||||||
// CreatePreview 把 dry-run 结果保存为 ready preview。
|
// CreatePreview 把 dry-run 结果保存为 ready preview。
|
||||||
//
|
//
|
||||||
// 职责边界:
|
// 职责边界:
|
||||||
// 1. 只消费已经完成的 dry-run 结果,不重新读取任务/日程事实;
|
// 1. 只消费已经完成的 dry-run 结果,不重新读取任务/日程事实;
|
||||||
// 2. MVP 没有 LLM 选择器,固定使用后端排序后的 top1 candidate 作为 selected_candidate;
|
// 2. 优先吃上层 selection 结果中的 selected_candidate_id / explanation / notification 摘要;
|
||||||
// 3. 写库后只返回详情 DTO,不发布通知、不正式应用候选、不回写 trigger。
|
// 若上层未显式传入,则为了兼容旧链路继续回退到 top1 candidate;
|
||||||
|
// 3. 写库后只返回详情 DTO,不发布通知、不正式应用候选、不回写 trigger。
|
||||||
func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) (*CreatePreviewResponse, error) {
|
func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) (*CreatePreviewResponse, error) {
|
||||||
if s == nil || s.repo == nil {
|
if s == nil || s.repo == nil {
|
||||||
return nil, fmt.Errorf("%w: preview service 未初始化", ErrInvalidPreviewRequest)
|
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()
|
previewID = "asp_" + uuid.NewString()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 先构造所有展示快照,再写库;任何 JSON 转换失败都提前返回,避免落入半结构化记录。
|
// 1. 先解析选中的候选,再构造展示快照;任何 JSON 转换失败都提前返回,避免落入半结构化记录。
|
||||||
selected := req.Candidates[0]
|
// 1.1 若上层已经给出 selected_candidate_id,就严格按该候选落库,避免 preview 与选择结果不一致。
|
||||||
snapshot := buildSnapshot(activeContext, req.Observation, req.Candidates, selected)
|
// 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)
|
baseVersion := strings.TrimSpace(req.BaseVersion)
|
||||||
if baseVersion == "" {
|
if baseVersion == "" {
|
||||||
baseVersion = buildBaseVersion(activeContext, snapshot.changes)
|
baseVersion = buildBaseVersion(activeContext, snapshot.changes)
|
||||||
@@ -170,6 +177,24 @@ func (s *Service) now() time.Time {
|
|||||||
return s.clock()
|
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(
|
func buildPreviewModel(
|
||||||
previewID string,
|
previewID string,
|
||||||
triggerID string,
|
triggerID string,
|
||||||
@@ -256,6 +281,7 @@ func buildSnapshot(
|
|||||||
observation observe.Result,
|
observation observe.Result,
|
||||||
candidates []candidate.Candidate,
|
candidates []candidate.Candidate,
|
||||||
selected candidate.Candidate,
|
selected candidate.Candidate,
|
||||||
|
fallbackUsed bool,
|
||||||
) rawPreviewSnapshot {
|
) rawPreviewSnapshot {
|
||||||
selectedDTO := candidateDTO(selected)
|
selectedDTO := candidateDTO(selected)
|
||||||
candidateDTOs := make([]CandidateDTO, 0, len(candidates))
|
candidateDTOs := make([]CandidateDTO, 0, len(candidates))
|
||||||
@@ -276,6 +302,6 @@ func buildSnapshot(
|
|||||||
before: before,
|
before: before,
|
||||||
changes: changes,
|
changes: changes,
|
||||||
after: after,
|
after: after,
|
||||||
risk: riskDTO(selected, observation, changes),
|
risk: riskDTO(selected, observation, changes, fallbackUsed),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
31
backend/active_scheduler/service/dry_run_graph.go
Normal file
31
backend/active_scheduler/service/dry_run_graph.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
302
backend/active_scheduler/service/session_bridge.go
Normal file
302
backend/active_scheduler/service/session_bridge.go
Normal 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
|
||||||
|
}
|
||||||
@@ -118,6 +118,7 @@ func BuildFeishuRequestedPayload(
|
|||||||
requestedAt time.Time,
|
requestedAt time.Time,
|
||||||
) sharedevents.FeishuNotificationRequestedPayload {
|
) sharedevents.FeishuNotificationRequestedPayload {
|
||||||
summary := strings.TrimSpace(notificationSummary)
|
summary := strings.TrimSpace(notificationSummary)
|
||||||
|
targetURL := fmt.Sprintf("/assistant/%s", buildActiveScheduleConversationID(triggerRow.ID))
|
||||||
return sharedevents.FeishuNotificationRequestedPayload{
|
return sharedevents.FeishuNotificationRequestedPayload{
|
||||||
UserID: triggerRow.UserID,
|
UserID: triggerRow.UserID,
|
||||||
TriggerID: triggerRow.ID,
|
TriggerID: triggerRow.ID,
|
||||||
@@ -126,9 +127,9 @@ func BuildFeishuRequestedPayload(
|
|||||||
TargetType: triggerRow.TargetType,
|
TargetType: triggerRow.TargetType,
|
||||||
TargetID: triggerRow.TargetID,
|
TargetID: triggerRow.TargetID,
|
||||||
DedupeKey: BuildNotificationDedupeKey(triggerRow.UserID, triggerRow.TriggerType, triggerRow.RequestedAt),
|
DedupeKey: BuildNotificationDedupeKey(triggerRow.UserID, triggerRow.TriggerType, triggerRow.RequestedAt),
|
||||||
TargetURL: fmt.Sprintf("/schedule-adjust/%s", strings.TrimSpace(previewID)),
|
TargetURL: targetURL,
|
||||||
SummaryText: summary,
|
SummaryText: summary,
|
||||||
FallbackText: buildNotificationFallbackText(summary, strings.TrimSpace(previewID)),
|
FallbackText: buildNotificationFallbackText(summary, targetURL),
|
||||||
TraceID: triggerRow.TraceID,
|
TraceID: triggerRow.TraceID,
|
||||||
RequestedAt: requestedAt,
|
RequestedAt: requestedAt,
|
||||||
}
|
}
|
||||||
@@ -201,8 +202,8 @@ func normalizeKafkaConfig(cfg kafkabus.Config) kafkabus.Config {
|
|||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildNotificationFallbackText(summary string, previewID string) string {
|
func buildNotificationFallbackText(summary string, targetURL string) string {
|
||||||
link := fmt.Sprintf("/schedule-adjust/%s", previewID)
|
link := strings.TrimSpace(targetURL)
|
||||||
if summary == "" {
|
if summary == "" {
|
||||||
return "你有一条新的日程调整建议,请查看:" + link
|
return "你有一条新的日程调整建议,请查看:" + link
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
activegraph "github.com/LoveLosita/smartflow/backend/active_scheduler/graph"
|
||||||
activepreview "github.com/LoveLosita/smartflow/backend/active_scheduler/preview"
|
activepreview "github.com/LoveLosita/smartflow/backend/active_scheduler/preview"
|
||||||
"github.com/LoveLosita/smartflow/backend/active_scheduler/trigger"
|
"github.com/LoveLosita/smartflow/backend/active_scheduler/trigger"
|
||||||
"github.com/LoveLosita/smartflow/backend/dao"
|
"github.com/LoveLosita/smartflow/backend/dao"
|
||||||
@@ -28,38 +29,57 @@ const (
|
|||||||
//
|
//
|
||||||
// 职责边界:
|
// 职责边界:
|
||||||
// 1. 只推进主动调度 trigger 的后台状态机,不负责启动 outbox worker;
|
// 1. 只推进主动调度 trigger 的后台状态机,不负责启动 outbox worker;
|
||||||
// 2. dry-run 与 preview 复用现有 service,不再单独实现第二套候选生成逻辑;
|
// 2. dry-run 与选择器都复用 active_scheduler 独立模块,不再往 newAgent 里塞主动调度逻辑;
|
||||||
// 3. notification 只发布 requested 事件,不直接接真实飞书 provider。
|
// 3. notification 只发布 requested 事件,不直接接真实飞书 provider。
|
||||||
type TriggerWorkflowService struct {
|
type TriggerWorkflowService struct {
|
||||||
activeDAO *dao.ActiveScheduleDAO
|
activeDAO *dao.ActiveScheduleDAO
|
||||||
dryRun *DryRunService
|
graphRunner *activegraph.Runner
|
||||||
outbox *outboxinfra.Repository
|
outbox *outboxinfra.Repository
|
||||||
kafkaCfg kafkabus.Config
|
kafkaCfg kafkabus.Config
|
||||||
clock func() time.Time
|
agentDAO *dao.AgentDAO
|
||||||
|
sessionDAO *dao.ActiveScheduleSessionDAO
|
||||||
|
clock func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTriggerWorkflowService(
|
func NewTriggerWorkflowService(
|
||||||
activeDAO *dao.ActiveScheduleDAO,
|
activeDAO *dao.ActiveScheduleDAO,
|
||||||
dryRun *DryRunService,
|
graphRunner *activegraph.Runner,
|
||||||
outboxRepo *outboxinfra.Repository,
|
outboxRepo *outboxinfra.Repository,
|
||||||
kafkaCfg kafkabus.Config,
|
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) {
|
) (*TriggerWorkflowService, error) {
|
||||||
if activeDAO == nil {
|
if activeDAO == nil {
|
||||||
return nil, errors.New("active schedule dao 不能为空")
|
return nil, errors.New("active schedule dao 不能为空")
|
||||||
}
|
}
|
||||||
if dryRun == nil {
|
if graphRunner == nil {
|
||||||
return nil, errors.New("dry-run service 不能为空")
|
return nil, errors.New("active scheduler graph runner 不能为空")
|
||||||
}
|
}
|
||||||
if outboxRepo == nil {
|
if outboxRepo == nil {
|
||||||
return nil, errors.New("outbox repository 不能为空")
|
return nil, errors.New("outbox repository 不能为空")
|
||||||
}
|
}
|
||||||
return &TriggerWorkflowService{
|
svc := &TriggerWorkflowService{
|
||||||
activeDAO: activeDAO,
|
activeDAO: activeDAO,
|
||||||
dryRun: dryRun,
|
graphRunner: graphRunner,
|
||||||
outbox: outboxRepo,
|
outbox: outboxRepo,
|
||||||
kafkaCfg: kafkaCfg,
|
kafkaCfg: kafkaCfg,
|
||||||
clock: time.Now,
|
clock: time.Now,
|
||||||
}, nil
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
if opt != nil {
|
||||||
|
opt(svc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return svc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TriggerWorkflowService) SetClock(clock func() time.Time) {
|
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 主链路。
|
// ProcessTriggeredInTx 在 outbox 消费事务内推进 trigger 主链路。
|
||||||
//
|
//
|
||||||
// 步骤化说明:
|
// 步骤化说明:
|
||||||
// 1. 先锁 trigger 行,确保同一 trigger 在并发 worker 下只能由一个事务推进;
|
// 1. 先锁 trigger 行,确保同一 trigger 在并发 worker 下只能由一个事务推进;
|
||||||
// 2. 再把状态切到 processing,避免排障时看不出消息已经被消费;
|
// 2. 再把状态切到 processing,避免排障时看不出消息已经被消费;
|
||||||
// 3. 复用 dry-run + preview service 生成预览;若发现已有 preview,则直接复用,避免重复写库;
|
// 3. 复用 active scheduler graph 跑 dry-run + 受限选择;若发现已有 preview,则直接复用,避免重复写库;
|
||||||
// 4. preview 成功后回写 trigger 状态,并在同一事务里补发 notification.requested outbox;
|
// 4. preview 成功后回写 trigger 状态,并在同一事务里补发 notification.requested outbox;
|
||||||
// 5. 任一步失败都返回 error,由外层 handler 负责记录 failed 状态并触发 outbox retry。
|
// 5. 任一步失败都返回 error,由外层 handler 负责记录 failed 状态并触发 outbox retry。
|
||||||
func (s *TriggerWorkflowService) ProcessTriggeredInTx(
|
func (s *TriggerWorkflowService) ProcessTriggeredInTx(
|
||||||
@@ -81,7 +104,7 @@ func (s *TriggerWorkflowService) ProcessTriggeredInTx(
|
|||||||
tx *gorm.DB,
|
tx *gorm.DB,
|
||||||
payload sharedevents.ActiveScheduleTriggeredPayload,
|
payload sharedevents.ActiveScheduleTriggeredPayload,
|
||||||
) error {
|
) 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 未初始化")
|
return errors.New("trigger workflow service 未初始化")
|
||||||
}
|
}
|
||||||
if tx == nil {
|
if tx == nil {
|
||||||
@@ -125,14 +148,18 @@ func (s *TriggerWorkflowService) ProcessTriggeredInTx(
|
|||||||
}
|
}
|
||||||
|
|
||||||
domainTrigger := buildDomainTriggerFromModel(*triggerRow, payload)
|
domainTrigger := buildDomainTriggerFromModel(*triggerRow, payload)
|
||||||
dryRunResult, err := s.dryRun.DryRun(ctx, domainTrigger)
|
graphResult, err := s.graphRunner.Run(ctx, domainTrigger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
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)
|
return s.markClosedWithoutPreview(ctx, txDAO, triggerRow.ID, now)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,11 +168,15 @@ func (s *TriggerWorkflowService) ProcessTriggeredInTx(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
previewResp, err := previewService.CreatePreview(ctx, activepreview.CreatePreviewRequest{
|
previewResp, err := previewService.CreatePreview(ctx, activepreview.CreatePreviewRequest{
|
||||||
ActiveContext: dryRunResult.Context,
|
ActiveContext: dryRunData.Context,
|
||||||
Observation: dryRunResult.Observation,
|
Observation: dryRunData.Observation,
|
||||||
Candidates: dryRunResult.Candidates,
|
Candidates: dryRunData.Candidates,
|
||||||
TriggerID: triggerRow.ID,
|
TriggerID: triggerRow.ID,
|
||||||
GeneratedAt: now,
|
GeneratedAt: now,
|
||||||
|
SelectedCandidateID: graphResult.SelectionResult.SelectedCandidateID,
|
||||||
|
ExplanationText: graphResult.SelectionResult.ExplanationText,
|
||||||
|
NotificationSummary: graphResult.SelectionResult.NotificationSummary,
|
||||||
|
FallbackUsed: graphResult.SelectionResult.FallbackUsed,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -162,10 +193,16 @@ func (s *TriggerWorkflowService) ProcessTriggeredInTx(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !dryRunResult.Observation.Decision.ShouldNotify {
|
if !dryRunData.Observation.Decision.ShouldNotify {
|
||||||
return nil
|
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(
|
notificationPayload := BuildFeishuRequestedPayload(
|
||||||
*triggerRow,
|
*triggerRow,
|
||||||
previewID,
|
previewID,
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ func (api *AgentHandler) ChatAgent(c *gin.Context) {
|
|||||||
// 设计说明:
|
// 设计说明:
|
||||||
// 1) 该接口用于配合 SSE 聊天链路:标题异步生成后,前端可通过 conversation_id 拉取;
|
// 1) 该接口用于配合 SSE 聊天链路:标题异步生成后,前端可通过 conversation_id 拉取;
|
||||||
// 2) 不依赖 SSE header 动态更新,避免“header 必须首包前写入”的协议限制;
|
// 2) 不依赖 SSE header 动态更新,避免“header 必须首包前写入”的协议限制;
|
||||||
// 3) 会话不存在时返回 400,避免前端把无效会话当成系统错误。
|
// 3) 会话不存在或不属于当前用户时返回 404,避免前端把无效会话误判成参数类型错误。
|
||||||
func (api *AgentHandler) GetConversationMeta(c *gin.Context) {
|
func (api *AgentHandler) GetConversationMeta(c *gin.Context) {
|
||||||
// 1. 读取 query 参数并做基础校验。
|
// 1. 读取 query 参数并做基础校验。
|
||||||
conversationID := strings.TrimSpace(c.Query("conversation_id"))
|
conversationID := strings.TrimSpace(c.Query("conversation_id"))
|
||||||
@@ -175,9 +175,9 @@ func (api *AgentHandler) GetConversationMeta(c *gin.Context) {
|
|||||||
// 4. 调 service 查询会话元信息。
|
// 4. 调 service 查询会话元信息。
|
||||||
meta, err := api.svc.GetConversationMeta(ctx, userID, conversationID)
|
meta, err := api.svc.GetConversationMeta(ctx, userID, conversationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 会话不存在按参数错误处理,返回 400 给前端更直观。
|
// 会话不存在或越权访问时返回 404,让前端能和“参数格式错误”区分开。
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
c.JSON(http.StatusNotFound, respond.ConversationNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respond.DealWithError(c, err)
|
respond.DealWithError(c, err)
|
||||||
@@ -255,7 +255,7 @@ func (api *AgentHandler) GetConversationList(c *gin.Context) {
|
|||||||
// 说明:
|
// 说明:
|
||||||
// 1. 该接口是新前端刷新重建的单一来源;
|
// 1. 该接口是新前端刷新重建的单一来源;
|
||||||
// 2. 返回结果已按 seq 升序,前端按数组顺序渲染即可;
|
// 2. 返回结果已按 seq 升序,前端按数组顺序渲染即可;
|
||||||
// 3. 会话不存在时统一返回 400,避免误判成系统异常。
|
// 3. 会话不存在或不属于当前用户时统一返回 404,避免误判成参数格式问题。
|
||||||
func (api *AgentHandler) GetConversationTimeline(c *gin.Context) {
|
func (api *AgentHandler) GetConversationTimeline(c *gin.Context) {
|
||||||
conversationID := strings.TrimSpace(c.Query("conversation_id"))
|
conversationID := strings.TrimSpace(c.Query("conversation_id"))
|
||||||
if conversationID == "" {
|
if conversationID == "" {
|
||||||
@@ -271,7 +271,7 @@ func (api *AgentHandler) GetConversationTimeline(c *gin.Context) {
|
|||||||
timeline, err := api.svc.GetConversationTimeline(ctx, userID, conversationID)
|
timeline, err := api.svc.GetConversationTimeline(ctx, userID, conversationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
c.JSON(http.StatusNotFound, respond.ConversationNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respond.DealWithError(c, err)
|
respond.DealWithError(c, err)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@@ -12,9 +13,12 @@ import (
|
|||||||
|
|
||||||
activeadapters "github.com/LoveLosita/smartflow/backend/active_scheduler/adapters"
|
activeadapters "github.com/LoveLosita/smartflow/backend/active_scheduler/adapters"
|
||||||
"github.com/LoveLosita/smartflow/backend/active_scheduler/applyadapter"
|
"github.com/LoveLosita/smartflow/backend/active_scheduler/applyadapter"
|
||||||
|
activegraph "github.com/LoveLosita/smartflow/backend/active_scheduler/graph"
|
||||||
activejob "github.com/LoveLosita/smartflow/backend/active_scheduler/job"
|
activejob "github.com/LoveLosita/smartflow/backend/active_scheduler/job"
|
||||||
activepreview "github.com/LoveLosita/smartflow/backend/active_scheduler/preview"
|
activepreview "github.com/LoveLosita/smartflow/backend/active_scheduler/preview"
|
||||||
|
activesel "github.com/LoveLosita/smartflow/backend/active_scheduler/selection"
|
||||||
activesvc "github.com/LoveLosita/smartflow/backend/active_scheduler/service"
|
activesvc "github.com/LoveLosita/smartflow/backend/active_scheduler/service"
|
||||||
|
activeTrigger "github.com/LoveLosita/smartflow/backend/active_scheduler/trigger"
|
||||||
"github.com/LoveLosita/smartflow/backend/api"
|
"github.com/LoveLosita/smartflow/backend/api"
|
||||||
"github.com/LoveLosita/smartflow/backend/dao"
|
"github.com/LoveLosita/smartflow/backend/dao"
|
||||||
kafkabus "github.com/LoveLosita/smartflow/backend/infra/kafka"
|
kafkabus "github.com/LoveLosita/smartflow/backend/infra/kafka"
|
||||||
@@ -30,12 +34,14 @@ import (
|
|||||||
"github.com/LoveLosita/smartflow/backend/model"
|
"github.com/LoveLosita/smartflow/backend/model"
|
||||||
newagentconv "github.com/LoveLosita/smartflow/backend/newAgent/conv"
|
newagentconv "github.com/LoveLosita/smartflow/backend/newAgent/conv"
|
||||||
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||||
|
newagentstream "github.com/LoveLosita/smartflow/backend/newAgent/stream"
|
||||||
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
|
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
|
||||||
"github.com/LoveLosita/smartflow/backend/newAgent/tools/web"
|
"github.com/LoveLosita/smartflow/backend/newAgent/tools/web"
|
||||||
"github.com/LoveLosita/smartflow/backend/notification"
|
"github.com/LoveLosita/smartflow/backend/notification"
|
||||||
"github.com/LoveLosita/smartflow/backend/pkg"
|
"github.com/LoveLosita/smartflow/backend/pkg"
|
||||||
"github.com/LoveLosita/smartflow/backend/routers"
|
"github.com/LoveLosita/smartflow/backend/routers"
|
||||||
"github.com/LoveLosita/smartflow/backend/service"
|
"github.com/LoveLosita/smartflow/backend/service"
|
||||||
|
agentsvcsvc "github.com/LoveLosita/smartflow/backend/service/agentsvc"
|
||||||
eventsvc "github.com/LoveLosita/smartflow/backend/service/events"
|
eventsvc "github.com/LoveLosita/smartflow/backend/service/events"
|
||||||
"github.com/go-redis/redis/v8"
|
"github.com/go-redis/redis/v8"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
@@ -211,7 +217,17 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) {
|
|||||||
courseService := buildCourseService(courseRepo, scheduleRepo)
|
courseService := buildCourseService(courseRepo, scheduleRepo)
|
||||||
taskClassService := service.NewTaskClassService(taskClassRepo, cacheRepo, scheduleRepo, manager)
|
taskClassService := service.NewTaskClassService(taskClassRepo, cacheRepo, scheduleRepo, manager)
|
||||||
scheduleService := service.NewScheduleService(scheduleRepo, userRepo, taskClassRepo, manager, cacheRepo)
|
scheduleService := service.NewScheduleService(scheduleRepo, userRepo, taskClassRepo, manager, cacheRepo)
|
||||||
agentService := service.NewAgentServiceWithSchedule(aiHub, agentRepo, taskRepo, cacheRepo, agentCacheRepo, eventBus, scheduleService, taskSv)
|
agentService := service.NewAgentServiceWithSchedule(
|
||||||
|
aiHub,
|
||||||
|
agentRepo,
|
||||||
|
taskRepo,
|
||||||
|
cacheRepo,
|
||||||
|
agentCacheRepo,
|
||||||
|
manager.ActiveScheduleSession,
|
||||||
|
eventBus,
|
||||||
|
scheduleService,
|
||||||
|
taskSv,
|
||||||
|
)
|
||||||
|
|
||||||
configureAgentService(
|
configureAgentService(
|
||||||
agentService,
|
agentService,
|
||||||
@@ -238,6 +254,15 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// 1. 主动调度选择器单独复用 Pro 模型,LLM 失败时由 selection 层显式回退到确定性候选;
|
||||||
|
// 2. dry-run 与 selection 通过 graph runner 串起来,避免 trigger_pipeline 再拼第二套候选逻辑。
|
||||||
|
activeScheduleLLMClient := infrallm.WrapArkClient(aiHub.Pro)
|
||||||
|
activeScheduleSelector := activesel.NewService(activeScheduleLLMClient)
|
||||||
|
activeScheduleGraphRunner, err := activegraph.NewRunner(activeScheduleDryRun.AsGraphDryRunFunc(), activeScheduleSelector)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
agentService.SetActiveScheduleSessionRerunFunc(buildActiveScheduleSessionRerunFunc(manager.ActiveSchedule, activeScheduleGraphRunner, activeSchedulePreviewConfirm))
|
||||||
// 1. 生产投递先切到用户级飞书 Webhook provider,mock provider 文件继续保留给后续单测和本地隔离验证。
|
// 1. 生产投递先切到用户级飞书 Webhook provider,mock provider 文件继续保留给后续单测和本地隔离验证。
|
||||||
// 2. provider 与配置测试接口共用同一个实例,保证“测试成功”和“正式投递”走同一套 URL 校验、JSON 拼装和 HTTP 结果分类。
|
// 2. provider 与配置测试接口共用同一个实例,保证“测试成功”和“正式投递”走同一套 URL 校验、JSON 拼装和 HTTP 结果分类。
|
||||||
feishuProvider, err := notification.NewWebhookFeishuProvider(manager.Notification, notification.WebhookFeishuProviderOptions{
|
feishuProvider, err := notification.NewWebhookFeishuProvider(manager.Notification, notification.WebhookFeishuProviderOptions{
|
||||||
@@ -257,7 +282,13 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) {
|
|||||||
var activeTriggerWorkflow *activesvc.TriggerWorkflowService
|
var activeTriggerWorkflow *activesvc.TriggerWorkflowService
|
||||||
var activeJobScanner *activejob.Scanner
|
var activeJobScanner *activejob.Scanner
|
||||||
if eventBus != nil {
|
if eventBus != nil {
|
||||||
activeTriggerWorkflow, err = activesvc.NewTriggerWorkflowService(manager.ActiveSchedule, activeScheduleDryRun, outboxRepo, kafkabus.LoadConfig())
|
activeTriggerWorkflow, err = activesvc.NewTriggerWorkflowServiceWithOptions(
|
||||||
|
manager.ActiveSchedule,
|
||||||
|
activeScheduleGraphRunner,
|
||||||
|
outboxRepo,
|
||||||
|
kafkabus.LoadConfig(),
|
||||||
|
activesvc.WithActiveScheduleSessionBridge(manager.Agent, manager.ActiveScheduleSession),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -359,6 +390,166 @@ func buildActiveSchedulePreviewConfirmService(db *gorm.DB, activeDAO *dao.Active
|
|||||||
return activesvc.NewPreviewConfirmService(dryRun, previewService, activeDAO, applyadapter.NewGormApplyAdapter(db))
|
return activesvc.NewPreviewConfirmService(dryRun, previewService, activeDAO, applyadapter.NewGormApplyAdapter(db))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildActiveScheduleSessionRerunFunc 把主动调度 graph / preview 能力装成聊天入口可调用的 rerun 闭包。
|
||||||
|
//
|
||||||
|
// 说明:
|
||||||
|
// 1. 这里只做最小接线:复用现有 trigger -> graph -> preview 组件,不把 worker/notification 再搬一遍;
|
||||||
|
// 2. 成功时返回 session 状态、assistant 文本和业务卡片数据;
|
||||||
|
// 3. 失败时直接把 error 交回聊天入口,由上层统一写失败日志和 SSE 错误。
|
||||||
|
func buildActiveScheduleSessionRerunFunc(
|
||||||
|
activeDAO *dao.ActiveScheduleDAO,
|
||||||
|
graphRunner *activegraph.Runner,
|
||||||
|
previewConfirm *activesvc.PreviewConfirmService,
|
||||||
|
) agentsvcsvc.ActiveScheduleSessionRerunFunc {
|
||||||
|
return func(
|
||||||
|
ctx context.Context,
|
||||||
|
session *model.ActiveScheduleSessionSnapshot,
|
||||||
|
userMessage string,
|
||||||
|
traceID string,
|
||||||
|
requestStart time.Time,
|
||||||
|
) (*agentsvcsvc.ActiveScheduleSessionRerunResult, error) {
|
||||||
|
if activeDAO == nil || graphRunner == nil || previewConfirm == nil {
|
||||||
|
return nil, fmt.Errorf("主动调度 rerun 依赖未初始化")
|
||||||
|
}
|
||||||
|
if session == nil {
|
||||||
|
return nil, fmt.Errorf("主动调度 session 不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerRow, err := activeDAO.GetTriggerByID(ctx, session.TriggerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 1. 当前最小接线先复用“原 trigger + 最新数据库事实”重跑 active scheduler graph。
|
||||||
|
// 2. 用户这次回复的正文已经由聊天入口写进 conversation/timeline,但还没有下沉到 active_scheduler readers。
|
||||||
|
// 3. 后续若要让 ask_user 回复直接改写 graph 事实源,应在 reader/context builder 层继续补这一跳。
|
||||||
|
domainTrigger := activeTrigger.ActiveScheduleTrigger{
|
||||||
|
TriggerID: triggerRow.ID,
|
||||||
|
UserID: triggerRow.UserID,
|
||||||
|
TriggerType: activeTrigger.TriggerType(triggerRow.TriggerType),
|
||||||
|
Source: activeTrigger.SourceUserFeedback,
|
||||||
|
TargetType: activeTrigger.TargetType(triggerRow.TargetType),
|
||||||
|
TargetID: triggerRow.TargetID,
|
||||||
|
FeedbackID: triggerRow.FeedbackID,
|
||||||
|
IdempotencyKey: triggerRow.IdempotencyKey,
|
||||||
|
MockNow: nil,
|
||||||
|
IsMockTime: false,
|
||||||
|
RequestedAt: requestStart,
|
||||||
|
TraceID: traceID,
|
||||||
|
}
|
||||||
|
if err := domainTrigger.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
graphResult, err := graphRunner.Run(ctx, domainTrigger)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if graphResult == nil || graphResult.DryRunData == nil || graphResult.DryRunData.Context == nil {
|
||||||
|
return nil, fmt.Errorf("主动调度 graph 返回空结果")
|
||||||
|
}
|
||||||
|
|
||||||
|
selectionResult := graphResult.SelectionResult
|
||||||
|
state := session.State
|
||||||
|
state.LastCandidateID = strings.TrimSpace(selectionResult.SelectedCandidateID)
|
||||||
|
state.LastNotificationID = ""
|
||||||
|
state.FailedReason = ""
|
||||||
|
state.MissingInfo = cloneStringSlice(graphResult.DryRunData.Context.DerivedFacts.MissingInfo)
|
||||||
|
|
||||||
|
switch selectionResult.Action {
|
||||||
|
case activesel.ActionSelectCandidate:
|
||||||
|
if !graphResult.DryRunData.Observation.Decision.ShouldWritePreview {
|
||||||
|
return nil, fmt.Errorf("主动调度 graph 选择了候选,但未产出可写 preview")
|
||||||
|
}
|
||||||
|
previewResp, err := previewConfirm.CreatePreviewFromDryRun(ctx, activepreview.CreatePreviewRequest{
|
||||||
|
ActiveContext: graphResult.DryRunData.Context,
|
||||||
|
Observation: graphResult.DryRunData.Observation,
|
||||||
|
Candidates: graphResult.DryRunData.Candidates,
|
||||||
|
TriggerID: triggerRow.ID,
|
||||||
|
GeneratedAt: requestStart,
|
||||||
|
SelectedCandidateID: selectionResult.SelectedCandidateID,
|
||||||
|
ExplanationText: selectionResult.ExplanationText,
|
||||||
|
NotificationSummary: selectionResult.NotificationSummary,
|
||||||
|
FallbackUsed: selectionResult.FallbackUsed,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
state.PendingQuestion = ""
|
||||||
|
state.MissingInfo = nil
|
||||||
|
state.FailedReason = ""
|
||||||
|
expiresAt := previewResp.Detail.ExpiresAt
|
||||||
|
state.ExpiresAt = &expiresAt
|
||||||
|
|
||||||
|
return &agentsvcsvc.ActiveScheduleSessionRerunResult{
|
||||||
|
AssistantText: firstNonEmptyString(selectionResult.ExplanationText, selectionResult.NotificationSummary, previewResp.Detail.Explanation, previewResp.Detail.Notification, "主动调度建议已更新。"),
|
||||||
|
BusinessCard: &newagentstream.StreamBusinessCardExtra{
|
||||||
|
CardType: "active_schedule_preview",
|
||||||
|
Title: "SmartFlow 日程调整建议",
|
||||||
|
Summary: firstNonEmptyString(selectionResult.NotificationSummary, previewResp.Detail.Notification, previewResp.Detail.Explanation),
|
||||||
|
Data: previewDetailToMap(previewResp.Detail),
|
||||||
|
},
|
||||||
|
SessionState: state,
|
||||||
|
SessionStatus: model.ActiveScheduleSessionStatusReadyPreview,
|
||||||
|
PreviewID: previewResp.Detail.PreviewID,
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
case activesel.ActionAskUser:
|
||||||
|
question := firstNonEmptyString(selectionResult.AskUserQuestion, selectionResult.ExplanationText, "请继续补充主动调度需要的信息。")
|
||||||
|
state.PendingQuestion = question
|
||||||
|
state.ExpiresAt = nil
|
||||||
|
return &agentsvcsvc.ActiveScheduleSessionRerunResult{
|
||||||
|
AssistantText: question,
|
||||||
|
SessionState: state,
|
||||||
|
SessionStatus: model.ActiveScheduleSessionStatusWaitingUserReply,
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
assistantText := firstNonEmptyString(selectionResult.ExplanationText, selectionResult.NotificationSummary, "当前主动调度暂时没有需要继续处理的内容。")
|
||||||
|
state.PendingQuestion = ""
|
||||||
|
state.MissingInfo = nil
|
||||||
|
state.ExpiresAt = nil
|
||||||
|
return &agentsvcsvc.ActiveScheduleSessionRerunResult{
|
||||||
|
AssistantText: assistantText,
|
||||||
|
SessionState: state,
|
||||||
|
SessionStatus: model.ActiveScheduleSessionStatusIgnored,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// previewDetailToMap 将 active_schedule preview 详情转成通用 map,供 timeline business_card 直接复用。
|
||||||
|
func previewDetailToMap(detail activepreview.ActiveSchedulePreviewDetail) map[string]any {
|
||||||
|
raw, err := json.Marshal(detail)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
var output map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &output); err != nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstNonEmptyString 负责在一组候选文本里挑出第一条可展示内容。
|
||||||
|
func firstNonEmptyString(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneStringSlice 负责复制 string 切片,避免直接复用底层数组被后续修改。
|
||||||
|
func cloneStringSlice(values []string) []string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copied := make([]string, len(values))
|
||||||
|
copy(copied, values)
|
||||||
|
return copied
|
||||||
|
}
|
||||||
|
|
||||||
func configureAgentService(
|
func configureAgentService(
|
||||||
agentService *service.AgentService,
|
agentService *service.AgentService,
|
||||||
ragRuntime infrarag.Runtime,
|
ragRuntime infrarag.Runtime,
|
||||||
@@ -487,12 +678,13 @@ func buildTaskClassUpsertFunc(taskClassRepo *dao.TaskClassDAO) func(userID int,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildQuickTaskCreateFunc(taskRepo *dao.TaskDAO) func(userID int, title string, priorityGroup int, deadlineAt *time.Time, urgencyThresholdAt *time.Time) (int, error) {
|
func buildQuickTaskCreateFunc(taskRepo *dao.TaskDAO) func(userID int, title string, priorityGroup int, estimatedSections int, deadlineAt *time.Time, urgencyThresholdAt *time.Time) (int, error) {
|
||||||
return func(userID int, title string, priorityGroup int, deadlineAt *time.Time, urgencyThresholdAt *time.Time) (int, error) {
|
return func(userID int, title string, priorityGroup int, estimatedSections int, deadlineAt *time.Time, urgencyThresholdAt *time.Time) (int, error) {
|
||||||
created, err := taskRepo.AddTask(&model.Task{
|
created, err := taskRepo.AddTask(&model.Task{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Title: title,
|
Title: title,
|
||||||
Priority: priorityGroup,
|
Priority: priorityGroup,
|
||||||
|
EstimatedSections: model.NormalizeEstimatedSections(&estimatedSections),
|
||||||
IsCompleted: false,
|
IsCompleted: false,
|
||||||
DeadlineAt: deadlineAt,
|
DeadlineAt: deadlineAt,
|
||||||
UrgencyThresholdAt: urgencyThresholdAt,
|
UrgencyThresholdAt: urgencyThresholdAt,
|
||||||
@@ -528,11 +720,12 @@ func buildQuickTaskQueryFunc(agentService *service.AgentService) func(ctx contex
|
|||||||
deadlineStr = r.DeadlineAt.In(time.Local).Format("2006-01-02 15:04")
|
deadlineStr = r.DeadlineAt.In(time.Local).Format("2006-01-02 15:04")
|
||||||
}
|
}
|
||||||
results = append(results, newagentmodel.TaskQueryResult{
|
results = append(results, newagentmodel.TaskQueryResult{
|
||||||
ID: r.ID,
|
ID: r.ID,
|
||||||
Title: r.Title,
|
Title: r.Title,
|
||||||
PriorityGroup: r.PriorityGroup,
|
PriorityGroup: r.PriorityGroup,
|
||||||
IsCompleted: r.IsCompleted,
|
EstimatedSections: model.NormalizeEstimatedSections(&r.EstimatedSections),
|
||||||
DeadlineAt: deadlineStr,
|
IsCompleted: r.IsCompleted,
|
||||||
|
DeadlineAt: deadlineStr,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return results, nil
|
return results, nil
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import (
|
|||||||
|
|
||||||
func UserAddTaskRequestToModel(request *model.UserAddTaskRequest, userID int) *model.Task {
|
func UserAddTaskRequestToModel(request *model.UserAddTaskRequest, userID int) *model.Task {
|
||||||
return &model.Task{
|
return &model.Task{
|
||||||
Title: request.Title,
|
Title: request.Title,
|
||||||
Priority: request.PriorityGroup,
|
Priority: request.PriorityGroup,
|
||||||
DeadlineAt: request.DeadlineAt,
|
EstimatedSections: model.NormalizeEstimatedSections(&request.EstimatedSections),
|
||||||
UserID: userID,
|
DeadlineAt: request.DeadlineAt,
|
||||||
|
UserID: userID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,12 +22,13 @@ func ModelToUserAddTaskResponse(task *model.Task) *model.UserAddTaskResponse {
|
|||||||
status = "completed"
|
status = "completed"
|
||||||
}
|
}
|
||||||
return &model.UserAddTaskResponse{
|
return &model.UserAddTaskResponse{
|
||||||
ID: task.ID,
|
ID: task.ID,
|
||||||
Title: task.Title,
|
Title: task.Title,
|
||||||
PriorityGroup: task.Priority,
|
PriorityGroup: task.Priority,
|
||||||
DeadlineAt: task.DeadlineAt,
|
EstimatedSections: model.NormalizeEstimatedSections(&task.EstimatedSections),
|
||||||
Status: status,
|
DeadlineAt: task.DeadlineAt,
|
||||||
CreatedAt: time.Now(), // 创建时间为当前时间
|
Status: status,
|
||||||
|
CreatedAt: time.Now(), // 创建时间为当前时间
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +55,7 @@ func ModelToGetUserTasksResp(tasks []model.Task) []model.GetUserTaskResp {
|
|||||||
UserID: task.UserID,
|
UserID: task.UserID,
|
||||||
Title: task.Title,
|
Title: task.Title,
|
||||||
PriorityGroup: task.Priority,
|
PriorityGroup: task.Priority,
|
||||||
|
EstimatedSections: model.NormalizeEstimatedSections(&task.EstimatedSections),
|
||||||
Status: status,
|
Status: status,
|
||||||
Deadline: deadline,
|
Deadline: deadline,
|
||||||
IsCompleted: task.IsCompleted,
|
IsCompleted: task.IsCompleted,
|
||||||
@@ -81,6 +84,7 @@ func ModelToGetUserTaskResp(task *model.Task) model.GetUserTaskResp {
|
|||||||
UserID: task.UserID,
|
UserID: task.UserID,
|
||||||
Title: task.Title,
|
Title: task.Title,
|
||||||
PriorityGroup: task.Priority,
|
PriorityGroup: task.Priority,
|
||||||
|
EstimatedSections: model.NormalizeEstimatedSections(&task.EstimatedSections),
|
||||||
Status: status,
|
Status: status,
|
||||||
Deadline: deadline,
|
Deadline: deadline,
|
||||||
IsCompleted: task.IsCompleted,
|
IsCompleted: task.IsCompleted,
|
||||||
|
|||||||
400
backend/dao/active_schedule_session.go
Normal file
400
backend/dao/active_schedule_session.go
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
package dao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
var activeScheduleSessionLiveStatuses = []string{
|
||||||
|
model.ActiveScheduleSessionStatusWaitingUserReply,
|
||||||
|
model.ActiveScheduleSessionStatusRerunning,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveScheduleSessionDAO 负责主动调度会话的数据库读写。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只管 session 表本身,不管聊天入口拦截策略;
|
||||||
|
// 2. 只提供按 session_id / conversation_id 的读写能力,不编排 graph;
|
||||||
|
// 3. cache 命中策略由上层决定,这里始终把 MySQL 当作最终真相。
|
||||||
|
type ActiveScheduleSessionDAO struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewActiveScheduleSessionDAO 创建主动调度会话 DAO。
|
||||||
|
func NewActiveScheduleSessionDAO(db *gorm.DB) *ActiveScheduleSessionDAO {
|
||||||
|
return &ActiveScheduleSessionDAO{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTx 基于外部事务句柄构造同事务 DAO。
|
||||||
|
func (d *ActiveScheduleSessionDAO) WithTx(tx *gorm.DB) *ActiveScheduleSessionDAO {
|
||||||
|
return &ActiveScheduleSessionDAO{db: tx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ActiveScheduleSessionDAO) ensureDB() error {
|
||||||
|
if d == nil || d.db == nil {
|
||||||
|
return errors.New("active schedule session dao 未初始化")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertActiveScheduleSession 按 session_id 幂等写入或覆盖主动调度会话。
|
||||||
|
//
|
||||||
|
// 步骤化说明:
|
||||||
|
// 1. 先校验主键、归属用户和状态,避免把脏会话写进数据表;
|
||||||
|
// 2. 再把轻量 state 统一序列化为 state_json,保证数据库侧格式稳定;
|
||||||
|
// 3. 最后走 OnConflict upsert,保留 created_at,仅刷新业务字段和 updated_at。
|
||||||
|
func (d *ActiveScheduleSessionDAO) UpsertActiveScheduleSession(ctx context.Context, snapshot *model.ActiveScheduleSessionSnapshot) error {
|
||||||
|
if err := d.ensureDB(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized, err := normalizeActiveScheduleSessionSnapshot(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stateJSON, err := marshalActiveScheduleSessionState(normalized.State)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal active schedule session state failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
row := model.ActiveScheduleSession{
|
||||||
|
SessionID: normalized.SessionID,
|
||||||
|
UserID: normalized.UserID,
|
||||||
|
ConversationID: nullableStringPtr(normalized.ConversationID),
|
||||||
|
TriggerID: normalized.TriggerID,
|
||||||
|
CurrentPreviewID: nullableStringPtr(normalized.CurrentPreviewID),
|
||||||
|
Status: normalized.Status,
|
||||||
|
StateJSON: stateJSON,
|
||||||
|
CreatedAt: normalized.CreatedAt,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if row.CreatedAt.IsZero() {
|
||||||
|
row.CreatedAt = now
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{
|
||||||
|
{Name: "session_id"},
|
||||||
|
},
|
||||||
|
DoUpdates: clause.Assignments(map[string]any{
|
||||||
|
"user_id": row.UserID,
|
||||||
|
"conversation_id": row.ConversationID,
|
||||||
|
"trigger_id": row.TriggerID,
|
||||||
|
"current_preview_id": row.CurrentPreviewID,
|
||||||
|
"status": row.Status,
|
||||||
|
"state_json": row.StateJSON,
|
||||||
|
"updated_at": row.UpdatedAt,
|
||||||
|
}),
|
||||||
|
}).Create(&row).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveScheduleSessionBySessionID 按 session_id 读取任意状态的会话记录。
|
||||||
|
//
|
||||||
|
// 返回语义:
|
||||||
|
// 1. 命中:返回 snapshot, nil;
|
||||||
|
// 2. 未命中:返回 nil, nil,交给上层判断是否需要走回源或新建;
|
||||||
|
// 3. 数据损坏:返回 error,避免把坏状态继续传给拦截逻辑。
|
||||||
|
func (d *ActiveScheduleSessionDAO) GetActiveScheduleSessionBySessionID(ctx context.Context, sessionID string) (*model.ActiveScheduleSessionSnapshot, error) {
|
||||||
|
if err := d.ensureDB(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedSessionID := strings.TrimSpace(sessionID)
|
||||||
|
if normalizedSessionID == "" {
|
||||||
|
return nil, errors.New("session_id is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
var row model.ActiveScheduleSession
|
||||||
|
err := d.db.WithContext(ctx).
|
||||||
|
Where("session_id = ?", normalizedSessionID).
|
||||||
|
First(&row).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return activeScheduleSessionSnapshotFromRow(&row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveScheduleSessionByConversationID 按 user_id + conversation_id 读取最新的会话记录。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 始终返回同一 conversation 最新的一条记录,方便上层直接判断当前 status;
|
||||||
|
// 2. 不在 DAO 内部做“是否拦截”的业务裁决,避免把路由规则写死在存储层;
|
||||||
|
// 3. 若同一 conversation 误写出多条记录,按最近更新时间优先返回。
|
||||||
|
func (d *ActiveScheduleSessionDAO) GetActiveScheduleSessionByConversationID(ctx context.Context, userID int, conversationID string) (*model.ActiveScheduleSessionSnapshot, error) {
|
||||||
|
if err := d.ensureDB(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if userID <= 0 {
|
||||||
|
return nil, fmt.Errorf("invalid user_id: %d", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedConversationID := strings.TrimSpace(conversationID)
|
||||||
|
if normalizedConversationID == "" {
|
||||||
|
return nil, errors.New("conversation_id is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
var row model.ActiveScheduleSession
|
||||||
|
err := d.db.WithContext(ctx).
|
||||||
|
Where("user_id = ? AND conversation_id = ?", userID, normalizedConversationID).
|
||||||
|
Order("updated_at DESC, created_at DESC, session_id DESC").
|
||||||
|
First(&row).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return activeScheduleSessionSnapshotFromRow(&row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateActiveScheduleSessionFieldsBySessionID 按 session_id 更新局部字段。
|
||||||
|
//
|
||||||
|
// 说明:
|
||||||
|
// 1. 这里不负责 state_json 的序列化,调用方需要自己准备好最终字段值;
|
||||||
|
// 2. 若 updates 为空,直接返回 nil,避免多余的数据库写入;
|
||||||
|
// 3. updated_at 会在这里自动刷新,保证时间线可追踪。
|
||||||
|
func (d *ActiveScheduleSessionDAO) UpdateActiveScheduleSessionFieldsBySessionID(ctx context.Context, sessionID string, updates map[string]any) error {
|
||||||
|
if err := d.ensureDB(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedSessionID := strings.TrimSpace(sessionID)
|
||||||
|
if normalizedSessionID == "" {
|
||||||
|
return errors.New("session_id is empty")
|
||||||
|
}
|
||||||
|
if len(updates) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedUpdates := cloneUpdateMap(updates)
|
||||||
|
if _, ok := normalizedUpdates["updated_at"]; !ok {
|
||||||
|
normalizedUpdates["updated_at"] = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.db.WithContext(ctx).
|
||||||
|
Model(&model.ActiveScheduleSession{}).
|
||||||
|
Where("session_id = ?", normalizedSessionID).
|
||||||
|
Updates(normalizedUpdates).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateActiveScheduleSessionFieldsByConversationID 按 user_id + conversation_id 更新最新记录的局部字段。
|
||||||
|
//
|
||||||
|
// 步骤化说明:
|
||||||
|
// 1. 先定位同一 conversation 最新的 session,再按 session_id 回写,避免一次 update 覆盖多条历史;
|
||||||
|
// 2. 再写入局部字段和 updated_at,保证状态变化可以按会话维度回写;
|
||||||
|
// 3. 找不到任何会话时直接返回,交给上层决定是否要新建 session 或释放普通聊天。
|
||||||
|
func (d *ActiveScheduleSessionDAO) UpdateActiveScheduleSessionFieldsByConversationID(ctx context.Context, userID int, conversationID string, updates map[string]any) error {
|
||||||
|
if err := d.ensureDB(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if userID <= 0 {
|
||||||
|
return fmt.Errorf("invalid user_id: %d", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedConversationID := strings.TrimSpace(conversationID)
|
||||||
|
if normalizedConversationID == "" {
|
||||||
|
return errors.New("conversation_id is empty")
|
||||||
|
}
|
||||||
|
if len(updates) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
row, err := d.GetActiveScheduleSessionByConversationID(ctx, userID, normalizedConversationID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if row == nil {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedUpdates := cloneUpdateMap(updates)
|
||||||
|
if _, ok := normalizedUpdates["updated_at"]; !ok {
|
||||||
|
normalizedUpdates["updated_at"] = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.db.WithContext(ctx).
|
||||||
|
Model(&model.ActiveScheduleSession{}).
|
||||||
|
Where("session_id = ?", row.SessionID).
|
||||||
|
Updates(normalizedUpdates).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeActiveScheduleSessionSnapshot(snapshot *model.ActiveScheduleSessionSnapshot) (*model.ActiveScheduleSessionSnapshot, error) {
|
||||||
|
if snapshot == nil {
|
||||||
|
return nil, errors.New("active schedule session snapshot is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedSessionID := strings.TrimSpace(snapshot.SessionID)
|
||||||
|
if normalizedSessionID == "" {
|
||||||
|
return nil, errors.New("session_id is empty")
|
||||||
|
}
|
||||||
|
if snapshot.UserID <= 0 {
|
||||||
|
return nil, fmt.Errorf("invalid user_id: %d", snapshot.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedStatus, err := normalizeActiveScheduleSessionStatus(snapshot.Status)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedTriggerID := strings.TrimSpace(snapshot.TriggerID)
|
||||||
|
if normalizedTriggerID == "" {
|
||||||
|
return nil, errors.New("trigger_id is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized := *snapshot
|
||||||
|
normalized.SessionID = normalizedSessionID
|
||||||
|
normalized.UserID = snapshot.UserID
|
||||||
|
normalized.ConversationID = strings.TrimSpace(snapshot.ConversationID)
|
||||||
|
normalized.TriggerID = normalizedTriggerID
|
||||||
|
normalized.CurrentPreviewID = strings.TrimSpace(snapshot.CurrentPreviewID)
|
||||||
|
normalized.Status = normalizedStatus
|
||||||
|
normalized.State = normalizeActiveScheduleSessionState(snapshot.State)
|
||||||
|
return &normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeActiveScheduleSessionStatus(raw string) (string, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||||
|
case model.ActiveScheduleSessionStatusWaitingUserReply:
|
||||||
|
return model.ActiveScheduleSessionStatusWaitingUserReply, nil
|
||||||
|
case model.ActiveScheduleSessionStatusRerunning:
|
||||||
|
return model.ActiveScheduleSessionStatusRerunning, nil
|
||||||
|
case model.ActiveScheduleSessionStatusReadyPreview:
|
||||||
|
return model.ActiveScheduleSessionStatusReadyPreview, nil
|
||||||
|
case model.ActiveScheduleSessionStatusApplied:
|
||||||
|
return model.ActiveScheduleSessionStatusApplied, nil
|
||||||
|
case model.ActiveScheduleSessionStatusIgnored:
|
||||||
|
return model.ActiveScheduleSessionStatusIgnored, nil
|
||||||
|
case model.ActiveScheduleSessionStatusExpired:
|
||||||
|
return model.ActiveScheduleSessionStatusExpired, nil
|
||||||
|
case model.ActiveScheduleSessionStatusFailed:
|
||||||
|
return model.ActiveScheduleSessionStatusFailed, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("invalid active schedule session status: %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeActiveScheduleSessionState(state model.ActiveScheduleSessionState) model.ActiveScheduleSessionState {
|
||||||
|
state.PendingQuestion = strings.TrimSpace(state.PendingQuestion)
|
||||||
|
state.LastCandidateID = strings.TrimSpace(state.LastCandidateID)
|
||||||
|
state.LastNotificationID = strings.TrimSpace(state.LastNotificationID)
|
||||||
|
state.FailedReason = strings.TrimSpace(state.FailedReason)
|
||||||
|
if state.ExpiresAt != nil && state.ExpiresAt.IsZero() {
|
||||||
|
state.ExpiresAt = nil
|
||||||
|
}
|
||||||
|
if len(state.MissingInfo) > 0 {
|
||||||
|
state.MissingInfo = dedupeAndTrimStrings(state.MissingInfo)
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalActiveScheduleSessionState(state model.ActiveScheduleSessionState) (string, error) {
|
||||||
|
normalized := normalizeActiveScheduleSessionState(state)
|
||||||
|
raw, err := json.Marshal(normalized)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
text := strings.TrimSpace(string(raw))
|
||||||
|
if text == "" {
|
||||||
|
return "{}", nil
|
||||||
|
}
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func unmarshalActiveScheduleSessionState(raw string) (model.ActiveScheduleSessionState, error) {
|
||||||
|
clean := strings.TrimSpace(raw)
|
||||||
|
if clean == "" || clean == "null" {
|
||||||
|
return model.ActiveScheduleSessionState{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var state model.ActiveScheduleSessionState
|
||||||
|
if err := json.Unmarshal([]byte(clean), &state); err != nil {
|
||||||
|
return model.ActiveScheduleSessionState{}, err
|
||||||
|
}
|
||||||
|
state = normalizeActiveScheduleSessionState(state)
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func activeScheduleSessionSnapshotFromRow(row *model.ActiveScheduleSession) (*model.ActiveScheduleSessionSnapshot, error) {
|
||||||
|
if row == nil {
|
||||||
|
return nil, errors.New("active schedule session row is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := unmarshalActiveScheduleSessionState(row.StateJSON)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal active schedule session state failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &model.ActiveScheduleSessionSnapshot{
|
||||||
|
SessionID: row.SessionID,
|
||||||
|
UserID: row.UserID,
|
||||||
|
ConversationID: nullableStringValue(row.ConversationID),
|
||||||
|
TriggerID: row.TriggerID,
|
||||||
|
CurrentPreviewID: nullableStringValue(row.CurrentPreviewID),
|
||||||
|
Status: row.Status,
|
||||||
|
State: state,
|
||||||
|
CreatedAt: row.CreatedAt,
|
||||||
|
UpdatedAt: row.UpdatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableStringPtr(raw string) *string {
|
||||||
|
normalized := strings.TrimSpace(raw)
|
||||||
|
if normalized == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableStringValue(raw *string) string {
|
||||||
|
if raw == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(*raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneUpdateMap(updates map[string]any) map[string]any {
|
||||||
|
cloned := make(map[string]any, len(updates)+1)
|
||||||
|
for key, value := range updates {
|
||||||
|
cloned[key] = value
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func dedupeAndTrimStrings(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 {
|
||||||
|
normalized := strings.TrimSpace(item)
|
||||||
|
if normalized == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[normalized]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[normalized] = struct{}{}
|
||||||
|
result = append(result, normalized)
|
||||||
|
}
|
||||||
|
if len(result) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -8,28 +8,30 @@ import (
|
|||||||
|
|
||||||
// RepoManager 聚合所有 DAO,供服务层做跨仓储事务编排。
|
// RepoManager 聚合所有 DAO,供服务层做跨仓储事务编排。
|
||||||
type RepoManager struct {
|
type RepoManager struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
Schedule *ScheduleDAO
|
Schedule *ScheduleDAO
|
||||||
Task *TaskDAO
|
Task *TaskDAO
|
||||||
Course *CourseDAO
|
Course *CourseDAO
|
||||||
TaskClass *TaskClassDAO
|
TaskClass *TaskClassDAO
|
||||||
User *UserDAO
|
User *UserDAO
|
||||||
Agent *AgentDAO
|
Agent *AgentDAO
|
||||||
ActiveSchedule *ActiveScheduleDAO
|
ActiveSchedule *ActiveScheduleDAO
|
||||||
Notification *NotificationChannelDAO
|
ActiveScheduleSession *ActiveScheduleSessionDAO
|
||||||
|
Notification *NotificationChannelDAO
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewManager(db *gorm.DB) *RepoManager {
|
func NewManager(db *gorm.DB) *RepoManager {
|
||||||
return &RepoManager{
|
return &RepoManager{
|
||||||
db: db,
|
db: db,
|
||||||
Schedule: NewScheduleDAO(db),
|
Schedule: NewScheduleDAO(db),
|
||||||
Task: NewTaskDAO(db),
|
Task: NewTaskDAO(db),
|
||||||
Course: NewCourseDAO(db),
|
Course: NewCourseDAO(db),
|
||||||
TaskClass: NewTaskClassDAO(db),
|
TaskClass: NewTaskClassDAO(db),
|
||||||
User: NewUserDAO(db),
|
User: NewUserDAO(db),
|
||||||
Agent: NewAgentDAO(db),
|
Agent: NewAgentDAO(db),
|
||||||
ActiveSchedule: NewActiveScheduleDAO(db),
|
ActiveSchedule: NewActiveScheduleDAO(db),
|
||||||
Notification: NewNotificationChannelDAO(db),
|
ActiveScheduleSession: NewActiveScheduleSessionDAO(db),
|
||||||
|
Notification: NewNotificationChannelDAO(db),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,15 +43,16 @@ func NewManager(db *gorm.DB) *RepoManager {
|
|||||||
// 3. 适用于 outbox 消费处理器这类“基础设施事务 + 业务事务合并”的场景。
|
// 3. 适用于 outbox 消费处理器这类“基础设施事务 + 业务事务合并”的场景。
|
||||||
func (m *RepoManager) WithTx(tx *gorm.DB) *RepoManager {
|
func (m *RepoManager) WithTx(tx *gorm.DB) *RepoManager {
|
||||||
return &RepoManager{
|
return &RepoManager{
|
||||||
db: tx,
|
db: tx,
|
||||||
Schedule: m.Schedule.WithTx(tx),
|
Schedule: m.Schedule.WithTx(tx),
|
||||||
Task: m.Task.WithTx(tx),
|
Task: m.Task.WithTx(tx),
|
||||||
TaskClass: m.TaskClass.WithTx(tx),
|
TaskClass: m.TaskClass.WithTx(tx),
|
||||||
Course: m.Course.WithTx(tx),
|
Course: m.Course.WithTx(tx),
|
||||||
User: m.User.WithTx(tx),
|
User: m.User.WithTx(tx),
|
||||||
Agent: m.Agent.WithTx(tx),
|
Agent: m.Agent.WithTx(tx),
|
||||||
ActiveSchedule: m.ActiveSchedule.WithTx(tx),
|
ActiveSchedule: m.ActiveSchedule.WithTx(tx),
|
||||||
Notification: m.Notification.WithTx(tx),
|
ActiveScheduleSession: m.ActiveScheduleSession.WithTx(tx),
|
||||||
|
Notification: m.Notification.WithTx(tx),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -622,6 +622,131 @@ func (d *CacheDAO) DeleteConversationTimelineFromCache(ctx context.Context, user
|
|||||||
// Key 设计:
|
// Key 设计:
|
||||||
// 1. 使用 smartflow:agent_state 前缀,与现有 key 命名空间隔离;
|
// 1. 使用 smartflow:agent_state 前缀,与现有 key 命名空间隔离;
|
||||||
// 2. 使用 conversationID 作为唯一标识,因为 agent 状态是按会话维度持久化的。
|
// 2. 使用 conversationID 作为唯一标识,因为 agent 状态是按会话维度持久化的。
|
||||||
|
const activeScheduleSessionCacheTTL = 2 * time.Hour
|
||||||
|
|
||||||
|
// activeScheduleSessionKey 生成 session_id 维度的主动调度会话缓存 key。
|
||||||
|
func (d *CacheDAO) activeScheduleSessionKey(sessionID string) string {
|
||||||
|
return fmt.Sprintf("smartflow:active_schedule_session:s:%s", strings.TrimSpace(sessionID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeScheduleSessionConversationKey 生成 user_id + conversation_id 维度的主动调度会话缓存 key。
|
||||||
|
func (d *CacheDAO) activeScheduleSessionConversationKey(userID int, conversationID string) string {
|
||||||
|
return fmt.Sprintf("smartflow:active_schedule_session:u:%d:c:%s", userID, strings.TrimSpace(conversationID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActiveScheduleSessionToCache 同步写入主动调度会话缓存。
|
||||||
|
//
|
||||||
|
// 步骤化说明:
|
||||||
|
// 1. 先校验 snapshot 和主键,避免把无效会话写进 Redis;
|
||||||
|
// 2. 再把同一份快照写入 session_id / conversation_id 两个维度的 key;
|
||||||
|
// 3. 若 conversation_id 还没绑定,只写 session_id key,避免生成空路由 key。
|
||||||
|
func (d *CacheDAO) SetActiveScheduleSessionToCache(ctx context.Context, snapshot *model.ActiveScheduleSessionSnapshot) error {
|
||||||
|
if d == nil || d.client == nil {
|
||||||
|
return errors.New("cache dao is not initialized")
|
||||||
|
}
|
||||||
|
if snapshot == nil {
|
||||||
|
return errors.New("active schedule session snapshot is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID := strings.TrimSpace(snapshot.SessionID)
|
||||||
|
if sessionID == "" {
|
||||||
|
return errors.New("session_id is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal active schedule session cache failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pipe := d.client.Pipeline()
|
||||||
|
pipe.Set(ctx, d.activeScheduleSessionKey(sessionID), data, activeScheduleSessionCacheTTL)
|
||||||
|
if conversationID := strings.TrimSpace(snapshot.ConversationID); conversationID != "" && snapshot.UserID > 0 {
|
||||||
|
pipe.Set(ctx, d.activeScheduleSessionConversationKey(snapshot.UserID, conversationID), data, activeScheduleSessionCacheTTL)
|
||||||
|
}
|
||||||
|
_, err = pipe.Exec(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveScheduleSessionFromCache 按 session_id 读取主动调度会话缓存。
|
||||||
|
func (d *CacheDAO) GetActiveScheduleSessionFromCache(ctx context.Context, sessionID string) (*model.ActiveScheduleSessionSnapshot, error) {
|
||||||
|
if d == nil || d.client == nil {
|
||||||
|
return nil, errors.New("cache dao is not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedSessionID := strings.TrimSpace(sessionID)
|
||||||
|
if normalizedSessionID == "" {
|
||||||
|
return nil, errors.New("session_id is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := d.client.Get(ctx, d.activeScheduleSessionKey(normalizedSessionID)).Result()
|
||||||
|
if errors.Is(err, redis.Nil) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot model.ActiveScheduleSessionSnapshot
|
||||||
|
if err = json.Unmarshal([]byte(raw), &snapshot); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal active schedule session cache failed: %w", err)
|
||||||
|
}
|
||||||
|
return &snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveScheduleSessionFromConversationCache 按 user_id + conversation_id 读取主动调度会话缓存。
|
||||||
|
func (d *CacheDAO) GetActiveScheduleSessionFromConversationCache(ctx context.Context, userID int, conversationID string) (*model.ActiveScheduleSessionSnapshot, error) {
|
||||||
|
if d == nil || d.client == nil {
|
||||||
|
return nil, errors.New("cache dao is not initialized")
|
||||||
|
}
|
||||||
|
if userID <= 0 {
|
||||||
|
return nil, fmt.Errorf("invalid user_id: %d", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedConversationID := strings.TrimSpace(conversationID)
|
||||||
|
if normalizedConversationID == "" {
|
||||||
|
return nil, errors.New("conversation_id is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := d.client.Get(ctx, d.activeScheduleSessionConversationKey(userID, normalizedConversationID)).Result()
|
||||||
|
if errors.Is(err, redis.Nil) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot model.ActiveScheduleSessionSnapshot
|
||||||
|
if err = json.Unmarshal([]byte(raw), &snapshot); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal active schedule session cache failed: %w", err)
|
||||||
|
}
|
||||||
|
return &snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteActiveScheduleSessionFromCache 删除主动调度会话缓存。
|
||||||
|
//
|
||||||
|
// 说明:
|
||||||
|
// 1. 会同时清理 session_id 和 conversation_id 两个维度,避免旧路由缓存残留;
|
||||||
|
// 2. conversation_id 为空时只清 session_id key;
|
||||||
|
// 3. 删除操作本身幂等,即使 key 不存在也视为成功。
|
||||||
|
func (d *CacheDAO) DeleteActiveScheduleSessionFromCache(ctx context.Context, sessionID string, userID int, conversationID string) error {
|
||||||
|
if d == nil || d.client == nil {
|
||||||
|
return errors.New("cache dao is not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedSessionID := strings.TrimSpace(sessionID)
|
||||||
|
if normalizedSessionID == "" {
|
||||||
|
return errors.New("session_id is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := []string{d.activeScheduleSessionKey(normalizedSessionID)}
|
||||||
|
if userID > 0 {
|
||||||
|
if normalizedConversationID := strings.TrimSpace(conversationID); normalizedConversationID != "" {
|
||||||
|
keys = append(keys, d.activeScheduleSessionConversationKey(userID, normalizedConversationID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d.client.Del(ctx, keys...).Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (d *CacheDAO) agentStateKey(conversationID string) string {
|
func (d *CacheDAO) agentStateKey(conversationID string) string {
|
||||||
return fmt.Sprintf("smartflow:agent_state:%s", conversationID)
|
return fmt.Sprintf("smartflow:agent_state:%s", conversationID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZ
|
|||||||
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
|
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
|
||||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
|
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
|
||||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
|
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
|
||||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||||
@@ -46,6 +47,7 @@ github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.m
|
|||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
|
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
|
||||||
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
|
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
|
||||||
|
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
@@ -146,6 +148,7 @@ github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx
|
|||||||
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/meguminnnnnnnnn/go-openai v0.1.1 h1:u/IMMgrj/d617Dh/8BKAwlcstD74ynOJzCtVl+y8xAs=
|
github.com/meguminnnnnnnnn/go-openai v0.1.1 h1:u/IMMgrj/d617Dh/8BKAwlcstD74ynOJzCtVl+y8xAs=
|
||||||
github.com/meguminnnnnnnnn/go-openai v0.1.1/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY=
|
github.com/meguminnnnnnnnn/go-openai v0.1.1/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY=
|
||||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
|
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
|
||||||
@@ -175,7 +178,11 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
|||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
|
||||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
|
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
|
||||||
|
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
|
||||||
|
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||||
@@ -302,6 +309,7 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/telemetry v0.0.0-20250710130107-8d8967aff50b/go.mod h1:4ZwOYna0/zsOKwuR5X/m0QFOJpSZvAxFfkQT+Erd9D4=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
@@ -367,7 +375,9 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||||
|
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ func autoMigrateModels(db *gorm.DB) error {
|
|||||||
&model.UserNotificationChannel{},
|
&model.UserNotificationChannel{},
|
||||||
&model.AgentOutboxMessage{},
|
&model.AgentOutboxMessage{},
|
||||||
&model.AgentScheduleState{},
|
&model.AgentScheduleState{},
|
||||||
|
&model.ActiveScheduleSession{},
|
||||||
&model.AgentStateSnapshotRecord{},
|
&model.AgentStateSnapshotRecord{},
|
||||||
&model.MemoryItem{},
|
&model.MemoryItem{},
|
||||||
&model.MemoryJob{},
|
&model.MemoryJob{},
|
||||||
|
|||||||
82
backend/model/active_schedule_session.go
Normal file
82
backend/model/active_schedule_session.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ActiveScheduleSessionStatusWaitingUserReply 表示当前会话正在等待用户补充信息,后端应拦截普通聊天。
|
||||||
|
ActiveScheduleSessionStatusWaitingUserReply = "waiting_user_reply"
|
||||||
|
// ActiveScheduleSessionStatusRerunning 表示用户已回复,主动调度图正在重跑,后端仍需拦截普通聊天。
|
||||||
|
ActiveScheduleSessionStatusRerunning = "rerunning"
|
||||||
|
// ActiveScheduleSessionStatusReadyPreview 表示已生成可展示预览,当前会话可以释放回普通聊天。
|
||||||
|
ActiveScheduleSessionStatusReadyPreview = "ready_preview"
|
||||||
|
// ActiveScheduleSessionStatusApplied 表示用户已确认应用,主动调度会话已经收口。
|
||||||
|
ActiveScheduleSessionStatusApplied = "applied"
|
||||||
|
// ActiveScheduleSessionStatusIgnored 表示用户明确忽略本次建议。
|
||||||
|
ActiveScheduleSessionStatusIgnored = "ignored"
|
||||||
|
// ActiveScheduleSessionStatusExpired 表示会话已过期,不再承担路由管辖权。
|
||||||
|
ActiveScheduleSessionStatusExpired = "expired"
|
||||||
|
// ActiveScheduleSessionStatusFailed 表示会话在绑定、重跑或写回过程中失败。
|
||||||
|
ActiveScheduleSessionStatusFailed = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ActiveScheduleSession 是“主动调度会话路由桥”的持久化模型。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只保存会话级路由权与轻量状态,不承载 preview 主表的完整业务内容;
|
||||||
|
// 2. conversation_id 允许在通知前为空,等站内会话绑定完成后再写入;
|
||||||
|
// 3. state_json 只存轻量状态,避免把重对象和历史消息继续塞进 session 表。
|
||||||
|
type ActiveScheduleSession struct {
|
||||||
|
SessionID string `gorm:"column:session_id;type:varchar(64);primaryKey"`
|
||||||
|
|
||||||
|
// 1. user_id + conversation_id 用于在聊天入口侧定位当前管辖中的主动调度会话。
|
||||||
|
// 2. conversation_id 允许为空,因此这里使用可空列,方便先建 session 再绑定会话。
|
||||||
|
UserID int `gorm:"column:user_id;not null;index:idx_active_schedule_sessions_user_conv,priority:1;index:idx_active_schedule_sessions_user_status_updated,priority:1"`
|
||||||
|
ConversationID *string `gorm:"column:conversation_id;type:varchar(128);index:idx_active_schedule_sessions_user_conv,priority:2;index:idx_active_schedule_sessions_conversation_status_updated,priority:1"`
|
||||||
|
|
||||||
|
// 3. trigger_id / current_preview_id 分别串起触发源与当前预览,方便后续审计和回放。
|
||||||
|
TriggerID string `gorm:"column:trigger_id;type:varchar(64);not null;index:idx_active_schedule_sessions_trigger_id"`
|
||||||
|
CurrentPreviewID *string `gorm:"column:current_preview_id;type:varchar(64);index:idx_active_schedule_sessions_preview_id"`
|
||||||
|
Status string `gorm:"column:status;type:varchar(32);not null;default:'waiting_user_reply';index:idx_active_schedule_sessions_user_status_updated,priority:2;index:idx_active_schedule_sessions_status_updated,priority:1;index:idx_active_schedule_sessions_conversation_status_updated,priority:2"`
|
||||||
|
StateJSON string `gorm:"column:state_json;type:json;not null"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index:idx_active_schedule_sessions_user_status_updated,priority:3;index:idx_active_schedule_sessions_status_updated,priority:2;index:idx_active_schedule_sessions_conversation_status_updated,priority:3"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 返回主动调度会话表名。
|
||||||
|
func (ActiveScheduleSession) TableName() string {
|
||||||
|
return "active_schedule_sessions"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveScheduleSessionState 是 session 表内 state_json 对应的轻量状态。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 这里只放“路由和补信息闭环”需要的少量字段;
|
||||||
|
// 2. 不承载完整 preview、正文历史或大块工具结果;
|
||||||
|
// 3. 便于 cache / DAO 之间直接复用同一份 JSON 语义。
|
||||||
|
type ActiveScheduleSessionState struct {
|
||||||
|
PendingQuestion string `json:"pending_question,omitempty"`
|
||||||
|
MissingInfo []string `json:"missing_info,omitempty"`
|
||||||
|
LastCandidateID string `json:"last_candidate_id,omitempty"`
|
||||||
|
LastNotificationID string `json:"last_notification_id,omitempty"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
|
FailedReason string `json:"failed_reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveScheduleSessionSnapshot 是 service、DAO、cache 之间共享的会话快照 DTO。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 负责在三层之间传递强类型会话状态;
|
||||||
|
// 2. 不负责业务决策,不负责拦截判定;
|
||||||
|
// 3. DAO 再把它拆成数据库列和 state_json,cache 则直接按 JSON 存取。
|
||||||
|
type ActiveScheduleSessionSnapshot struct {
|
||||||
|
SessionID string
|
||||||
|
UserID int
|
||||||
|
ConversationID string
|
||||||
|
TriggerID string
|
||||||
|
CurrentPreviewID string
|
||||||
|
Status string
|
||||||
|
State ActiveScheduleSessionState
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
@@ -48,19 +48,40 @@ type Task struct {
|
|||||||
EstimatedSections int `gorm:"column:estimated_sections;not null;default:1"`
|
EstimatedSections int `gorm:"column:estimated_sections;not null;default:1"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NormalizeEstimatedSections 将预计节数收敛到 MVP 允许范围。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只处理默认值与越界收敛,不判断业务优先级,也不关心调用方来源;
|
||||||
|
// 2. nil、0、负数统一回退到 1;超过 4 的值收敛到 4,保证写库与读回口径一致。
|
||||||
|
func NormalizeEstimatedSections(raw *int) int {
|
||||||
|
if raw == nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
value := *raw
|
||||||
|
if value < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if value > 4 {
|
||||||
|
return 4
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
type UserAddTaskResponse struct {
|
type UserAddTaskResponse struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
PriorityGroup int `json:"priority_group"`
|
PriorityGroup int `json:"priority_group"`
|
||||||
DeadlineAt *time.Time `json:"deadline_at"`
|
EstimatedSections int `json:"estimated_sections"`
|
||||||
Status string `json:"status"`
|
DeadlineAt *time.Time `json:"deadline_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserAddTaskRequest struct {
|
type UserAddTaskRequest struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
PriorityGroup int `json:"priority_group"`
|
PriorityGroup int `json:"priority_group"`
|
||||||
DeadlineAt *time.Time `json:"deadline_at"`
|
EstimatedSections int `json:"estimated_sections"`
|
||||||
|
DeadlineAt *time.Time `json:"deadline_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserCompleteTaskRequest 是"标记任务完成"接口的请求体。
|
// UserCompleteTaskRequest 是"标记任务完成"接口的请求体。
|
||||||
@@ -114,6 +135,7 @@ type GetUserTaskResp struct {
|
|||||||
UserID int `json:"user_id"`
|
UserID int `json:"user_id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
PriorityGroup int `json:"priority_group"`
|
PriorityGroup int `json:"priority_group"`
|
||||||
|
EstimatedSections int `json:"estimated_sections"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Deadline string `json:"deadline"`
|
Deadline string `json:"deadline"`
|
||||||
IsCompleted bool `json:"is_completed"`
|
IsCompleted bool `json:"is_completed"`
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ type AgentGraphDeps struct {
|
|||||||
// 2. 这里只保留“创建任务 / 查询任务”两类轻量能力,避免再回退到已下线的孤立工具链。
|
// 2. 这里只保留“创建任务 / 查询任务”两类轻量能力,避免再回退到已下线的孤立工具链。
|
||||||
type QuickTaskDeps struct {
|
type QuickTaskDeps struct {
|
||||||
// CreateTask 创建一条四象限任务,返回 task_id。
|
// CreateTask 创建一条四象限任务,返回 task_id。
|
||||||
CreateTask func(userID int, title string, priorityGroup int, deadlineAt *time.Time, urgencyThresholdAt *time.Time) (taskID int, err error)
|
CreateTask func(userID int, title string, priorityGroup int, estimatedSections int, deadlineAt *time.Time, urgencyThresholdAt *time.Time) (taskID int, err error)
|
||||||
// QueryTasks 按条件查询用户任务列表。
|
// QueryTasks 按条件查询用户任务列表。
|
||||||
QueryTasks func(ctx context.Context, userID int, params TaskQueryParams) ([]TaskQueryResult, error)
|
QueryTasks func(ctx context.Context, userID int, params TaskQueryParams) ([]TaskQueryResult, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,11 @@ type TaskQueryParams struct {
|
|||||||
// 2. 结果既可用于 quick_task 节点文本回复,也可供 service 装配其他轻量输出;
|
// 2. 结果既可用于 quick_task 节点文本回复,也可供 service 装配其他轻量输出;
|
||||||
// 3. 不负责序列化策略和文案渲染。
|
// 3. 不负责序列化策略和文案渲染。
|
||||||
type TaskQueryResult struct {
|
type TaskQueryResult struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
PriorityGroup int `json:"priority_group"`
|
PriorityGroup int `json:"priority_group"`
|
||||||
PriorityLabel string `json:"priority_label"`
|
EstimatedSections int `json:"estimated_sections"`
|
||||||
IsCompleted bool `json:"is_completed"`
|
PriorityLabel string `json:"priority_label"`
|
||||||
DeadlineAt string `json:"deadline_at,omitempty"`
|
IsCompleted bool `json:"is_completed"`
|
||||||
|
DeadlineAt string `json:"deadline_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type TaskQueryTaskRecord struct {
|
|||||||
ID int
|
ID int
|
||||||
Title string
|
Title string
|
||||||
PriorityGroup int
|
PriorityGroup int
|
||||||
|
EstimatedSections int
|
||||||
IsCompleted bool
|
IsCompleted bool
|
||||||
DeadlineAt *time.Time
|
DeadlineAt *time.Time
|
||||||
UrgencyThresholdAt *time.Time
|
UrgencyThresholdAt *time.Time
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
infrallm "github.com/LoveLosita/smartflow/backend/infra/llm"
|
infrallm "github.com/LoveLosita/smartflow/backend/infra/llm"
|
||||||
|
taskmodel "github.com/LoveLosita/smartflow/backend/model"
|
||||||
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||||
newagentprompt "github.com/LoveLosita/smartflow/backend/newAgent/prompt"
|
newagentprompt "github.com/LoveLosita/smartflow/backend/newAgent/prompt"
|
||||||
newagentrouter "github.com/LoveLosita/smartflow/backend/newAgent/router"
|
newagentrouter "github.com/LoveLosita/smartflow/backend/newAgent/router"
|
||||||
@@ -41,6 +42,7 @@ type quickTaskDecision struct {
|
|||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
DeadlineAt string `json:"deadline_at,omitempty"`
|
DeadlineAt string `json:"deadline_at,omitempty"`
|
||||||
PriorityGroup *int `json:"priority_group,omitempty"`
|
PriorityGroup *int `json:"priority_group,omitempty"`
|
||||||
|
EstimatedSections *int `json:"estimated_sections,omitempty"`
|
||||||
UrgencyThresholdAt string `json:"urgency_threshold_at,omitempty"`
|
UrgencyThresholdAt string `json:"urgency_threshold_at,omitempty"`
|
||||||
TaskID *int `json:"task_id,omitempty"`
|
TaskID *int `json:"task_id,omitempty"`
|
||||||
|
|
||||||
@@ -137,8 +139,8 @@ func RunQuickTaskNode(ctx context.Context, input QuickTaskNodeInput) error {
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
log.Printf("[DEBUG] quick_task: 解析结果 chat=%s action=%s title=%s deadline_at=%s priority_group=%v urgency_threshold_at=%q",
|
log.Printf("[DEBUG] quick_task: 解析结果 chat=%s action=%s title=%s deadline_at=%s priority_group=%v estimated_sections=%v urgency_threshold_at=%q",
|
||||||
flowState.ConversationID, decision.Action, decision.Title, decision.DeadlineAt, decision.PriorityGroup, decision.UrgencyThresholdAt)
|
flowState.ConversationID, decision.Action, decision.Title, decision.DeadlineAt, decision.PriorityGroup, decision.EstimatedSections, decision.UrgencyThresholdAt)
|
||||||
|
|
||||||
// 阶段二:流式推送标签后正文。
|
// 阶段二:流式推送标签后正文。
|
||||||
if visible != "" {
|
if visible != "" {
|
||||||
@@ -266,6 +268,7 @@ func handleQuickTaskCreate(
|
|||||||
if priorityGroup == 0 {
|
if priorityGroup == 0 {
|
||||||
priorityGroup = quickNoteFallbackPriority(deadline)
|
priorityGroup = quickNoteFallbackPriority(deadline)
|
||||||
}
|
}
|
||||||
|
estimatedSections := taskmodel.NormalizeEstimatedSections(decision.EstimatedSections)
|
||||||
|
|
||||||
var urgencyThreshold *time.Time
|
var urgencyThreshold *time.Time
|
||||||
if raw := strings.TrimSpace(decision.UrgencyThresholdAt); raw != "" {
|
if raw := strings.TrimSpace(decision.UrgencyThresholdAt); raw != "" {
|
||||||
@@ -280,9 +283,9 @@ func handleQuickTaskCreate(
|
|||||||
urgencyThreshold = &fallback
|
urgencyThreshold = &fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[DEBUG] quick_task: CreateTask 参数 chat=%s title=%s priorityGroup=%d deadline=%v urgencyThreshold=%v urgency_raw=%q",
|
log.Printf("[DEBUG] quick_task: CreateTask 参数 chat=%s title=%s priorityGroup=%d estimatedSections=%d deadline=%v urgencyThreshold=%v urgency_raw=%q estimated_raw=%v",
|
||||||
flowState.ConversationID, title, priorityGroup, deadline, urgencyThreshold, decision.UrgencyThresholdAt)
|
flowState.ConversationID, title, priorityGroup, estimatedSections, deadline, urgencyThreshold, decision.UrgencyThresholdAt, decision.EstimatedSections)
|
||||||
taskID, err := input.QuickTaskDeps.CreateTask(flowState.UserID, title, priorityGroup, deadline, urgencyThreshold)
|
taskID, err := input.QuickTaskDeps.CreateTask(flowState.UserID, title, priorityGroup, estimatedSections, deadline, urgencyThreshold)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return quickTaskActionResult{AssistantText: fmt.Sprintf("记录失败了(%s),稍后再试试?", err)}
|
return quickTaskActionResult{AssistantText: fmt.Sprintf("记录失败了(%s),稍后再试试?", err)}
|
||||||
}
|
}
|
||||||
@@ -290,7 +293,7 @@ func handleQuickTaskCreate(
|
|||||||
flowState.UsedQuickNote = true
|
flowState.UsedQuickNote = true
|
||||||
return quickTaskActionResult{
|
return quickTaskActionResult{
|
||||||
AssistantText: "已帮你记下这条任务。",
|
AssistantText: "已帮你记下这条任务。",
|
||||||
BusinessCard: buildTaskRecordBusinessCard(taskID, title, priorityGroup, deadline, urgencyThreshold),
|
BusinessCard: buildTaskRecordBusinessCard(taskID, title, priorityGroup, estimatedSections, deadline, urgencyThreshold),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,13 +354,14 @@ func handleQuickTaskQuery(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildTaskRecordBusinessCard(taskID int, title string, priorityGroup int, deadline *time.Time, urgencyThreshold *time.Time) *newagentstream.StreamBusinessCardExtra {
|
func buildTaskRecordBusinessCard(taskID int, title string, priorityGroup int, estimatedSections int, deadline *time.Time, urgencyThreshold *time.Time) *newagentstream.StreamBusinessCardExtra {
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"id": taskID,
|
"id": taskID,
|
||||||
"title": strings.TrimSpace(title),
|
"title": strings.TrimSpace(title),
|
||||||
"priority_group": priorityGroup,
|
"priority_group": priorityGroup,
|
||||||
"priority_label": newagentshared.PriorityLabelCN(priorityGroup),
|
"estimated_sections": estimatedSections,
|
||||||
"status": "todo",
|
"priority_label": newagentshared.PriorityLabelCN(priorityGroup),
|
||||||
|
"status": "todo",
|
||||||
}
|
}
|
||||||
if formatted := formatQuickTaskTime(deadline); formatted != "" {
|
if formatted := formatQuickTaskTime(deadline); formatted != "" {
|
||||||
data["deadline_at"] = formatted
|
data["deadline_at"] = formatted
|
||||||
@@ -383,11 +387,12 @@ func buildTaskQueryBusinessCard(params newagentmodel.TaskQueryParams, results []
|
|||||||
taskItems := make([]map[string]any, 0, len(results))
|
taskItems := make([]map[string]any, 0, len(results))
|
||||||
for _, task := range results {
|
for _, task := range results {
|
||||||
item := map[string]any{
|
item := map[string]any{
|
||||||
"id": task.ID,
|
"id": task.ID,
|
||||||
"title": strings.TrimSpace(task.Title),
|
"title": strings.TrimSpace(task.Title),
|
||||||
"priority_group": task.PriorityGroup,
|
"priority_group": task.PriorityGroup,
|
||||||
"priority_label": newagentshared.PriorityLabelCN(task.PriorityGroup),
|
"estimated_sections": task.EstimatedSections,
|
||||||
"is_completed": task.IsCompleted,
|
"priority_label": newagentshared.PriorityLabelCN(task.PriorityGroup),
|
||||||
|
"is_completed": task.IsCompleted,
|
||||||
}
|
}
|
||||||
if deadline := strings.TrimSpace(task.DeadlineAt); deadline != "" {
|
if deadline := strings.TrimSpace(task.DeadlineAt); deadline != "" {
|
||||||
item["deadline_at"] = deadline
|
item["deadline_at"] = deadline
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const quickTaskSystemPrompt = `
|
|||||||
|
|
||||||
JSON 字段说明:
|
JSON 字段说明:
|
||||||
- action:只能是 create / query / ask
|
- action:只能是 create / query / ask
|
||||||
- create 时:title 必填,deadline_at 必填,priority_group 必填,范围 1-4;urgency_threshold_at 满足条件时填写,条件在下面
|
- create 时:title 必填,deadline_at 必填,priority_group 必填,范围 1-4;estimated_sections 必填,范围 1-4,不确定默认 1;urgency_threshold_at 满足条件时填写,条件在下面
|
||||||
- query 时:quadrant 可选 1-4,keyword 可选,limit 可选,deadline_after/deadline_before 可选(用于截止时间窗口筛选)
|
- query 时:quadrant 可选 1-4,keyword 可选,limit 可选,deadline_after/deadline_before 可选(用于截止时间窗口筛选)
|
||||||
- ask 时:question 必填
|
- ask 时:question 必填
|
||||||
|
|
||||||
@@ -37,9 +37,9 @@ JSON 字段说明:
|
|||||||
|
|
||||||
示例:
|
示例:
|
||||||
|
|
||||||
<SMARTFLOW_DECISION>{"action":"create","title":"明天开会","deadline_at":"明天下午3点"}</SMARTFLOW_DECISION>
|
<SMARTFLOW_DECISION>{"action":"create","title":"明天开会","deadline_at":"明天下午3点","estimated_sections":1}</SMARTFLOW_DECISION>
|
||||||
好的,我来帮你记一下。
|
好的,我来帮你记一下。
|
||||||
<SMARTFLOW_DECISION>{"action":"create","title":"下周交报告","deadline_at":"下周五 18:00","priority_group":2,"urgency_threshold_at":"下周四 09:00"}</SMARTFLOW_DECISION>
|
<SMARTFLOW_DECISION>{"action":"create","title":"下周交报告","deadline_at":"下周五 18:00","priority_group":2,"estimated_sections":2,"urgency_threshold_at":"下周四 09:00"}</SMARTFLOW_DECISION>
|
||||||
好的,我也帮你记一下。
|
好的,我也帮你记一下。
|
||||||
|
|
||||||
<SMARTFLOW_DECISION>{"action":"query","limit":5}</SMARTFLOW_DECISION>
|
<SMARTFLOW_DECISION>{"action":"query","limit":5}</SMARTFLOW_DECISION>
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ func (s *ChannelService) TestFeishuWebhook(ctx context.Context, userID int) (Tes
|
|||||||
TriggerType: "manual_test",
|
TriggerType: "manual_test",
|
||||||
TargetType: "notification_channel",
|
TargetType: "notification_channel",
|
||||||
TargetID: 0,
|
TargetID: 0,
|
||||||
TargetURL: "/schedule-adjust/asp_test_webhook",
|
TargetURL: "/assistant/00000000-0000-0000-0000-000000000000",
|
||||||
MessageText: "这是一条 SmartFlow 飞书 Webhook 测试消息。",
|
MessageText: "这是一条 SmartFlow 飞书 Webhook 测试消息。",
|
||||||
TraceID: traceID,
|
TraceID: traceID,
|
||||||
AttemptCount: 1,
|
AttemptCount: 1,
|
||||||
|
|||||||
@@ -334,6 +334,11 @@ var ( //请求相关的响应
|
|||||||
Info: "schedule plan preview not found",
|
Info: "schedule plan preview not found",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ConversationNotFound = Response{ //会话不存在或不属于当前用户
|
||||||
|
Status: "40401",
|
||||||
|
Info: "conversation not found",
|
||||||
|
}
|
||||||
|
|
||||||
MissingConversationID = Response{ //确认/恢复请求缺少会话ID
|
MissingConversationID = Response{ //确认/恢复请求缺少会话ID
|
||||||
Status: "40054",
|
Status: "40054",
|
||||||
Info: "conversation_id is required when confirm_action is present",
|
Info: "conversation_id is required when confirm_action is present",
|
||||||
|
|||||||
@@ -16,10 +16,19 @@ type AgentService = agentsvc.AgentService
|
|||||||
// NewAgentService 是迁移期兼容构造函数。
|
// NewAgentService 是迁移期兼容构造函数。
|
||||||
//
|
//
|
||||||
// 说明:
|
// 说明:
|
||||||
// 1) 外部调用签名不变,新增排程依赖通过可选方式注入(见 NewAgentServiceWithSchedule);
|
// 1) 继续保留 service 层入口形式,避免 api/cmd 侧直接感知 agentsvc 包路径;
|
||||||
// 2) 真实构造逻辑已下沉到 service/agentsvc 包。
|
// 2) 主动调度 session DAO 也在这里显式透传,避免聊天入口再去回查全局单例;
|
||||||
func NewAgentService(aiHub *inits.AIHub, repo *dao.AgentDAO, taskRepo *dao.TaskDAO, cacheDAO *dao.CacheDAO, agentRedis *dao.AgentCache, eventPublisher outboxinfra.EventPublisher) *AgentService {
|
// 3) 真实构造逻辑已下沉到 service/agentsvc 包。
|
||||||
return agentsvc.NewAgentService(aiHub, repo, taskRepo, cacheDAO, agentRedis, eventPublisher)
|
func NewAgentService(
|
||||||
|
aiHub *inits.AIHub,
|
||||||
|
repo *dao.AgentDAO,
|
||||||
|
taskRepo *dao.TaskDAO,
|
||||||
|
cacheDAO *dao.CacheDAO,
|
||||||
|
agentRedis *dao.AgentCache,
|
||||||
|
activeSessionDAO *dao.ActiveScheduleSessionDAO,
|
||||||
|
eventPublisher outboxinfra.EventPublisher,
|
||||||
|
) *AgentService {
|
||||||
|
return agentsvc.NewAgentService(aiHub, repo, taskRepo, cacheDAO, agentRedis, activeSessionDAO, eventPublisher)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgentServiceWithSchedule 在基础 AgentService 上注入排程依赖。
|
// NewAgentServiceWithSchedule 在基础 AgentService 上注入排程依赖。
|
||||||
@@ -27,18 +36,19 @@ func NewAgentService(aiHub *inits.AIHub, repo *dao.AgentDAO, taskRepo *dao.TaskD
|
|||||||
// 设计目的:
|
// 设计目的:
|
||||||
// 1) 通过函数注入避免 agentsvc 包直接依赖 service 层的 ScheduleService;
|
// 1) 通过函数注入避免 agentsvc 包直接依赖 service 层的 ScheduleService;
|
||||||
// 2) 排程依赖为可选:未注入时排程路由自动回退到普通聊天;
|
// 2) 排程依赖为可选:未注入时排程路由自动回退到普通聊天;
|
||||||
// 3) 保持 NewAgentService 签名不变,向下兼容。
|
// 3) 主动调度 session DAO 仍沿用统一构造注入,避免排程分支自己拼装仓储。
|
||||||
func NewAgentServiceWithSchedule(
|
func NewAgentServiceWithSchedule(
|
||||||
aiHub *inits.AIHub,
|
aiHub *inits.AIHub,
|
||||||
repo *dao.AgentDAO,
|
repo *dao.AgentDAO,
|
||||||
taskRepo *dao.TaskDAO,
|
taskRepo *dao.TaskDAO,
|
||||||
cacheDAO *dao.CacheDAO,
|
cacheDAO *dao.CacheDAO,
|
||||||
agentRedis *dao.AgentCache,
|
agentRedis *dao.AgentCache,
|
||||||
|
activeSessionDAO *dao.ActiveScheduleSessionDAO,
|
||||||
eventPublisher outboxinfra.EventPublisher,
|
eventPublisher outboxinfra.EventPublisher,
|
||||||
scheduleSvc *ScheduleService,
|
scheduleSvc *ScheduleService,
|
||||||
taskSvc *TaskService,
|
taskSvc *TaskService,
|
||||||
) *AgentService {
|
) *AgentService {
|
||||||
svc := agentsvc.NewAgentService(aiHub, repo, taskRepo, cacheDAO, agentRedis, eventPublisher)
|
svc := agentsvc.NewAgentService(aiHub, repo, taskRepo, cacheDAO, agentRedis, activeSessionDAO, eventPublisher)
|
||||||
|
|
||||||
// 注入排程依赖:将 service 层方法包装为函数闭包,避免循环依赖。
|
// 注入排程依赖:将 service 层方法包装为函数闭包,避免循环依赖。
|
||||||
if scheduleSvc != nil {
|
if scheduleSvc != nil {
|
||||||
|
|||||||
@@ -26,12 +26,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AgentService struct {
|
type AgentService struct {
|
||||||
AIHub *inits.AIHub
|
AIHub *inits.AIHub
|
||||||
repo *dao.AgentDAO
|
repo *dao.AgentDAO
|
||||||
taskRepo *dao.TaskDAO
|
taskRepo *dao.TaskDAO
|
||||||
cacheDAO *dao.CacheDAO
|
cacheDAO *dao.CacheDAO
|
||||||
agentCache *dao.AgentCache
|
agentCache *dao.AgentCache
|
||||||
eventPublisher outboxinfra.EventPublisher
|
activeScheduleSessionDAO *dao.ActiveScheduleSessionDAO
|
||||||
|
eventPublisher outboxinfra.EventPublisher
|
||||||
|
|
||||||
// ── 排程计划依赖(函数注入,避免 service 包循环依赖)──
|
// ── 排程计划依赖(函数注入,避免 service 包循环依赖)──
|
||||||
|
|
||||||
@@ -66,24 +67,34 @@ type AgentService struct {
|
|||||||
memoryCfg memorymodel.Config
|
memoryCfg memorymodel.Config
|
||||||
memoryObserver memoryobserve.Observer
|
memoryObserver memoryobserve.Observer
|
||||||
memoryMetrics memoryobserve.MetricsRecorder
|
memoryMetrics memoryobserve.MetricsRecorder
|
||||||
|
activeRerunFunc ActiveScheduleSessionRerunFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgentService 构造 AgentService。
|
// NewAgentService 构造 AgentService。
|
||||||
// 这里通过依赖注入把“模型、仓储、缓存、异步持久化通道”统一交给服务层管理,
|
// 这里通过依赖注入把“模型、仓储、缓存、异步持久化通道”统一交给服务层管理,
|
||||||
// 便于后续在单测中替换实现,或在启动流程中按环境切换配置。
|
// 便于后续在单测中替换实现,或在启动流程中按环境切换配置。
|
||||||
func NewAgentService(aiHub *inits.AIHub, repo *dao.AgentDAO, taskRepo *dao.TaskDAO, cacheDAO *dao.CacheDAO, agentRedis *dao.AgentCache, eventPublisher outboxinfra.EventPublisher) *AgentService {
|
func NewAgentService(
|
||||||
|
aiHub *inits.AIHub,
|
||||||
|
repo *dao.AgentDAO,
|
||||||
|
taskRepo *dao.TaskDAO,
|
||||||
|
cacheDAO *dao.CacheDAO,
|
||||||
|
agentRedis *dao.AgentCache,
|
||||||
|
activeSessionDAO *dao.ActiveScheduleSessionDAO,
|
||||||
|
eventPublisher outboxinfra.EventPublisher,
|
||||||
|
) *AgentService {
|
||||||
// 全局注册一次 token 采集 callback:
|
// 全局注册一次 token 采集 callback:
|
||||||
// 1. 只注册一次,避免重复处理;
|
// 1. 只注册一次,避免重复处理;
|
||||||
// 2. 只有带 RequestTokenMeter 的请求上下文才会真正累加。
|
// 2. 只有带 RequestTokenMeter 的请求上下文才会真正累加。
|
||||||
ensureTokenMeterCallbackRegistered()
|
ensureTokenMeterCallbackRegistered()
|
||||||
|
|
||||||
return &AgentService{
|
return &AgentService{
|
||||||
AIHub: aiHub,
|
AIHub: aiHub,
|
||||||
repo: repo,
|
repo: repo,
|
||||||
taskRepo: taskRepo,
|
taskRepo: taskRepo,
|
||||||
cacheDAO: cacheDAO,
|
cacheDAO: cacheDAO,
|
||||||
agentCache: agentRedis,
|
agentCache: agentRedis,
|
||||||
eventPublisher: eventPublisher,
|
activeScheduleSessionDAO: activeSessionDAO,
|
||||||
|
eventPublisher: eventPublisher,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
323
backend/service/agentsvc/agent_active_schedule_session.go
Normal file
323
backend/service/agentsvc/agent_active_schedule_session.go
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
package agentsvc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/model"
|
||||||
|
newagentstream "github.com/LoveLosita/smartflow/backend/newAgent/stream"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ActiveScheduleSessionRerunFunc 表示主动调度 session 被聊天入口接管后,如何同步推进 rerun。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只负责把“当前 session + 用户回复”推进为新的主动调度结果;
|
||||||
|
// 2. 不负责决定 session 何时创建,也不负责通知投递;
|
||||||
|
// 3. 返回的结果只面向聊天入口的可见消息和 session 状态回写。
|
||||||
|
type ActiveScheduleSessionRerunFunc func(
|
||||||
|
ctx context.Context,
|
||||||
|
session *model.ActiveScheduleSessionSnapshot,
|
||||||
|
userMessage string,
|
||||||
|
traceID string,
|
||||||
|
requestStart time.Time,
|
||||||
|
) (*ActiveScheduleSessionRerunResult, error)
|
||||||
|
|
||||||
|
// ActiveScheduleSessionRerunResult 是主动调度 rerun 的最小返回结果。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只承载聊天入口需要回写的可见消息、业务卡片和 session 状态;
|
||||||
|
// 2. 不直接暴露 DAO 行,也不承载 worker / notification 的副作用;
|
||||||
|
// 3. AssistantText 为空时,调用方可降级为使用卡片摘要。
|
||||||
|
type ActiveScheduleSessionRerunResult struct {
|
||||||
|
AssistantText string
|
||||||
|
BusinessCard *newagentstream.StreamBusinessCardExtra
|
||||||
|
SessionState model.ActiveScheduleSessionState
|
||||||
|
SessionStatus string
|
||||||
|
PreviewID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActiveScheduleSessionRerunFunc 注入主动调度 rerun 入口。
|
||||||
|
func (s *AgentService) SetActiveScheduleSessionRerunFunc(fn ActiveScheduleSessionRerunFunc) {
|
||||||
|
s.activeRerunFunc = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadActiveScheduleSessionByConversation 尽量从缓存 + 数据库读取当前会话的主动调度 session。
|
||||||
|
//
|
||||||
|
// 步骤化说明:
|
||||||
|
// 1. 先读 Redis 热缓存,命中则直接返回;
|
||||||
|
// 2. 缓存未命中再回源数据库,避免把 session 状态逻辑绑死在缓存上;
|
||||||
|
// 3. 回源成功后尽力回填缓存,减少下一轮聊天入口的 DB 压力。
|
||||||
|
func (s *AgentService) loadActiveScheduleSessionByConversation(ctx context.Context, userID int, chatID string) (*model.ActiveScheduleSessionSnapshot, error) {
|
||||||
|
if s == nil || s.activeScheduleSessionDAO == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
normalizedChatID := strings.TrimSpace(chatID)
|
||||||
|
if userID <= 0 || normalizedChatID == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.cacheDAO != nil {
|
||||||
|
cached, err := s.cacheDAO.GetActiveScheduleSessionFromConversationCache(ctx, userID, normalizedChatID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("读取主动调度 session 缓存失败 user=%d chat=%s err=%v", userID, normalizedChatID, err)
|
||||||
|
} else if cached != nil {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
row, err := s.activeScheduleSessionDAO.GetActiveScheduleSessionByConversationID(ctx, userID, normalizedChatID)
|
||||||
|
if err != nil || row == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if s.cacheDAO != nil {
|
||||||
|
if cacheErr := s.cacheDAO.SetActiveScheduleSessionToCache(ctx, row); cacheErr != nil {
|
||||||
|
log.Printf("回填主动调度 session 缓存失败 user=%d chat=%s err=%v", userID, normalizedChatID, cacheErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistActiveScheduleSessionBestEffort 负责把主动调度 session 的最新状态同步回 MySQL 和 Redis。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. MySQL 是最终真相,先写表再回填缓存;
|
||||||
|
// 2. 缓存失败只记日志,不影响主流程;
|
||||||
|
// 3. 调用方需要先把 snapshot 改成最终状态,再交给这里落盘。
|
||||||
|
func (s *AgentService) persistActiveScheduleSessionBestEffort(ctx context.Context, snapshot *model.ActiveScheduleSessionSnapshot) error {
|
||||||
|
if s == nil || s.activeScheduleSessionDAO == nil || snapshot == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(snapshot.SessionID) == "" {
|
||||||
|
return errors.New("active schedule session_id 不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.activeScheduleSessionDAO.UpsertActiveScheduleSession(ctx, snapshot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 重新读取一遍,拿到数据库侧最终落表后的标准快照,减少缓存和 DB 的口径漂移。
|
||||||
|
// 2. 如果重读失败,也不影响主链路返回,只要主表已成功写入即可。
|
||||||
|
normalized, err := s.activeScheduleSessionDAO.GetActiveScheduleSessionBySessionID(ctx, snapshot.SessionID)
|
||||||
|
if err == nil && normalized != nil {
|
||||||
|
snapshot = normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.cacheDAO != nil {
|
||||||
|
if cacheErr := s.cacheDAO.SetActiveScheduleSessionToCache(ctx, snapshot); cacheErr != nil {
|
||||||
|
log.Printf("回填主动调度 session 缓存失败 session=%s err=%v", snapshot.SessionID, cacheErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleActiveScheduleSessionChat 处理被主动调度 session 占管的聊天入口。
|
||||||
|
//
|
||||||
|
// 步骤化说明:
|
||||||
|
// 1. 先读 session,判断当前 conversation 是否仍在 waiting_user_reply / rerunning 占管期;
|
||||||
|
// 2. 占管期间先把用户消息写入历史和时间线,保证会话内容不丢失;
|
||||||
|
// 3. waiting_user_reply 进入 rerunning,并同步调用主动调度 rerun;
|
||||||
|
// 4. rerunning 则只提示“正在重跑”,避免同一 conversation 被并发重复推进;
|
||||||
|
// 5. 终态或非占管态直接放行普通 newAgent。
|
||||||
|
func (s *AgentService) handleActiveScheduleSessionChat(
|
||||||
|
ctx context.Context,
|
||||||
|
userMessage string,
|
||||||
|
traceID string,
|
||||||
|
requestStart time.Time,
|
||||||
|
userID int,
|
||||||
|
chatID string,
|
||||||
|
resolvedModelName string,
|
||||||
|
outChan chan<- string,
|
||||||
|
errChan chan error,
|
||||||
|
) (bool, error) {
|
||||||
|
session, err := s.loadActiveScheduleSessionByConversation(ctx, userID, chatID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if session == nil || !isActiveScheduleSessionBlockingStatus(session.Status) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmedMessage := strings.TrimSpace(userMessage)
|
||||||
|
if trimmedMessage != "" {
|
||||||
|
// 1. 主动调度占管期间,用户每次回复仍然要进入正常会话历史。
|
||||||
|
// 2. 这样后续刷新聊天页时,用户可见消息、时间线和 session 状态不会彼此脱节。
|
||||||
|
if err := s.persistNewAgentConversationMessage(ctx, userID, chatID, schema.UserMessage(trimmedMessage), 0); err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch session.Status {
|
||||||
|
case model.ActiveScheduleSessionStatusWaitingUserReply:
|
||||||
|
if trimmedMessage == "" {
|
||||||
|
assistantText := strings.TrimSpace(session.State.PendingQuestion)
|
||||||
|
if assistantText == "" {
|
||||||
|
assistantText = "请先补充主动调度需要的关键信息。"
|
||||||
|
}
|
||||||
|
if err := s.persistNewAgentConversationMessage(ctx, userID, chatID, schema.AssistantMessage(assistantText, nil), 0); err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
emitActiveScheduleAssistantChunk(outChan, traceID, resolvedModelName, requestStart, assistantText, nil)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
// 1. 收到用户补充信息后,先把 session 切成 rerunning,避免并发请求继续按旧状态走普通聊天。
|
||||||
|
// 2. 这个阶段只是状态切换,不代表 graph 已经完成。
|
||||||
|
session.Status = model.ActiveScheduleSessionStatusRerunning
|
||||||
|
if err := s.persistActiveScheduleSessionBestEffort(ctx, session); err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
return true, s.runActiveScheduleSessionRerun(ctx, session, trimmedMessage, traceID, requestStart, resolvedModelName, outChan, errChan)
|
||||||
|
case model.ActiveScheduleSessionStatusRerunning:
|
||||||
|
// 1. rerunning 是占管中的过渡态,说明当前会话已经在重跑或刚开始重跑。
|
||||||
|
// 2. 这里不再触发第二次 rerun,只给用户一个可见的等待提示。
|
||||||
|
if trimmedMessage != "" {
|
||||||
|
assistantText := "主动调度正在重新生成建议,请稍后再试。"
|
||||||
|
if err := s.persistNewAgentConversationMessage(ctx, userID, chatID, schema.AssistantMessage(assistantText, nil), 0); err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
emitActiveScheduleAssistantChunk(outChan, traceID, resolvedModelName, requestStart, assistantText, nil)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
default:
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runActiveScheduleSessionRerun 负责把 waiting_user_reply 的用户补充同步推进成新的主动调度结果。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只负责聊天入口的最小编排,不复制 worker / notification 链路;
|
||||||
|
// 2. 成功时把新 preview / ask_user / close 的结果写回 session + timeline;
|
||||||
|
// 3. 失败时把 session 标成 failed,方便后续排障。
|
||||||
|
func (s *AgentService) runActiveScheduleSessionRerun(
|
||||||
|
ctx context.Context,
|
||||||
|
session *model.ActiveScheduleSessionSnapshot,
|
||||||
|
userMessage string,
|
||||||
|
traceID string,
|
||||||
|
requestStart time.Time,
|
||||||
|
resolvedModelName string,
|
||||||
|
outChan chan<- string,
|
||||||
|
errChan chan error,
|
||||||
|
) error {
|
||||||
|
if s == nil || s.activeRerunFunc == nil {
|
||||||
|
return errors.New("主动调度 rerun 未接入")
|
||||||
|
}
|
||||||
|
if session == nil {
|
||||||
|
return errors.New("active schedule session 不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := s.activeRerunFunc(ctx, session, userMessage, traceID, requestStart)
|
||||||
|
if err != nil {
|
||||||
|
session.Status = model.ActiveScheduleSessionStatusFailed
|
||||||
|
session.State.FailedReason = strings.TrimSpace(err.Error())
|
||||||
|
_ = s.persistActiveScheduleSessionBestEffort(ctx, session)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
result = &ActiveScheduleSessionRerunResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalStatus := strings.TrimSpace(result.SessionStatus)
|
||||||
|
if finalStatus == "" {
|
||||||
|
if result.BusinessCard != nil {
|
||||||
|
finalStatus = model.ActiveScheduleSessionStatusReadyPreview
|
||||||
|
} else {
|
||||||
|
finalStatus = model.ActiveScheduleSessionStatusWaitingUserReply
|
||||||
|
}
|
||||||
|
}
|
||||||
|
session.Status = finalStatus
|
||||||
|
session.State = result.SessionState
|
||||||
|
if strings.TrimSpace(result.PreviewID) != "" {
|
||||||
|
session.CurrentPreviewID = strings.TrimSpace(result.PreviewID)
|
||||||
|
} else if session.Status != model.ActiveScheduleSessionStatusReadyPreview {
|
||||||
|
session.CurrentPreviewID = ""
|
||||||
|
}
|
||||||
|
if session.Status == model.ActiveScheduleSessionStatusReadyPreview {
|
||||||
|
session.State.PendingQuestion = ""
|
||||||
|
session.State.MissingInfo = nil
|
||||||
|
session.State.FailedReason = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.persistActiveScheduleSessionBestEffort(ctx, session); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
assistantText := strings.TrimSpace(result.AssistantText)
|
||||||
|
if assistantText == "" && result.BusinessCard != nil {
|
||||||
|
assistantText = strings.TrimSpace(result.BusinessCard.Summary)
|
||||||
|
}
|
||||||
|
if assistantText == "" {
|
||||||
|
assistantText = "主动调度建议已更新。"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 把新结果写进 conversation history,保证刷新后仍然能看到 rerun 的正文。
|
||||||
|
// 2. 再追加业务卡片时间线,前端可以按 timeline 重建主动调度卡片。
|
||||||
|
if err := s.persistNewAgentConversationMessage(ctx, session.UserID, session.ConversationID, schema.AssistantMessage(assistantText, nil), 0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result.BusinessCard != nil {
|
||||||
|
if _, err := s.appendConversationTimelineEvent(
|
||||||
|
ctx,
|
||||||
|
session.UserID,
|
||||||
|
session.ConversationID,
|
||||||
|
model.AgentTimelineKindBusinessCard,
|
||||||
|
"assistant",
|
||||||
|
assistantText,
|
||||||
|
map[string]any{"business_card": result.BusinessCard},
|
||||||
|
0,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emitActiveScheduleAssistantChunk(outChan, traceID, resolvedModelName, requestStart, assistantText, nil)
|
||||||
|
if result.BusinessCard != nil {
|
||||||
|
emitActiveScheduleBusinessCardChunk(outChan, session.SessionID, traceID, resolvedModelName, requestStart, result.BusinessCard)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isActiveScheduleSessionBlockingStatus(status string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||||
|
case model.ActiveScheduleSessionStatusWaitingUserReply,
|
||||||
|
model.ActiveScheduleSessionStatusRerunning:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func emitActiveScheduleAssistantChunk(outChan chan<- string, traceID string, modelName string, requestStart time.Time, text string, extra *newagentstream.OpenAIChunkExtra) {
|
||||||
|
payload, err := newagentstream.ToOpenAIAssistantChunkWithExtra(traceID, modelName, requestStart.Unix(), strings.TrimSpace(text), true, extra)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("构造主动调度 assistant chunk 失败 trace=%s err=%v", traceID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pushChunkNonBlocking(outChan, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func emitActiveScheduleBusinessCardChunk(outChan chan<- string, blockID string, traceID string, modelName string, requestStart time.Time, card *newagentstream.StreamBusinessCardExtra) {
|
||||||
|
if card == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, err := newagentstream.ToOpenAIStreamWithExtra(nil, traceID, modelName, requestStart.Unix(), true, newagentstream.NewBusinessCardExtra(blockID, "active_schedule_session", card))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("构造主动调度 business card chunk 失败 trace=%s err=%v", traceID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pushChunkNonBlocking(outChan, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushChunkNonBlocking(outChan chan<- string, payload string) {
|
||||||
|
if outChan == nil || strings.TrimSpace(payload) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case outChan <- payload:
|
||||||
|
default:
|
||||||
|
log.Printf("主动调度 SSE 通道已满,丢弃 payload")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,12 +87,21 @@ func (s *AgentService) runNewAgentGraph(
|
|||||||
|
|
||||||
// 3. retry 机制已下线,不再构建重试元数据。
|
// 3. retry 机制已下线,不再构建重试元数据。
|
||||||
|
|
||||||
// 4. 从 StateStore 加载或创建 RuntimeState。
|
// 4. 如果当前 conversation 被主动调度 session 占管,先走 session 分支,不进入普通 newAgent。
|
||||||
|
// 这样 waiting_user_reply / rerunning 期间,用户消息会先推动主动调度闭环,而不是误进自由聊天。
|
||||||
|
if handled, sessionErr := s.handleActiveScheduleSessionChat(requestCtx, userMessage, traceID, requestStart, userID, chatID, resolvedModelName, outChan, errChan); sessionErr != nil {
|
||||||
|
pushErrNonBlocking(errChan, sessionErr)
|
||||||
|
return
|
||||||
|
} else if handled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 从 StateStore 加载或创建 RuntimeState。
|
||||||
// 恢复场景(confirm/ask_user)同时拿到快照中保存的 ConversationContext,
|
// 恢复场景(confirm/ask_user)同时拿到快照中保存的 ConversationContext,
|
||||||
// 其中包含工具调用/结果等中间消息,保证后续 LLM 调用的消息链完整。
|
// 其中包含工具调用/结果等中间消息,保证后续 LLM 调用的消息链完整。
|
||||||
runtimeState, savedConversationContext, savedScheduleState, savedOriginalScheduleState := s.loadOrCreateRuntimeState(requestCtx, chatID, userID)
|
runtimeState, savedConversationContext, savedScheduleState, savedOriginalScheduleState := s.loadOrCreateRuntimeState(requestCtx, chatID, userID)
|
||||||
|
|
||||||
// 5. 构造 ConversationContext。
|
// 6. 构造 ConversationContext。
|
||||||
// 优先使用快照中恢复的 ConversationContext(含工具调用/结果),
|
// 优先使用快照中恢复的 ConversationContext(含工具调用/结果),
|
||||||
// 无快照时从 Redis LLM 历史缓存加载。
|
// 无快照时从 Redis LLM 历史缓存加载。
|
||||||
var conversationContext *newagentmodel.ConversationContext
|
var conversationContext *newagentmodel.ConversationContext
|
||||||
@@ -105,17 +114,17 @@ func (s *AgentService) runNewAgentGraph(
|
|||||||
} else {
|
} else {
|
||||||
conversationContext = s.loadConversationContext(requestCtx, chatID, userMessage)
|
conversationContext = s.loadConversationContext(requestCtx, chatID, userMessage)
|
||||||
}
|
}
|
||||||
// 5.1. 在 graph 执行前统一补充与当前输入相关的记忆上下文(预取管线模式)。
|
// 6.1. 在 graph 执行前统一补充与当前输入相关的记忆上下文(预取管线模式)。
|
||||||
// 5.1.1 先读 Redis 预取缓存注入到 ConversationContext,再启动后台 goroutine 做完整检索;
|
// 6.1.1 先读 Redis 预取缓存注入到 ConversationContext,再启动后台 goroutine 做完整检索;
|
||||||
// 5.1.2 返回的 channel 传入 Deps,供 Execute/Plan 节点在启动前消费最新记忆;
|
// 6.1.2 返回的 channel 传入 Deps,供 Execute/Plan 节点在启动前消费最新记忆;
|
||||||
// 5.1.3 检索失败只降级为"本轮不注入记忆",不阻断主链路。
|
// 6.1.3 检索失败只降级为"本轮不注入记忆",不阻断主链路。
|
||||||
memoryFuture := s.injectMemoryContext(requestCtx, conversationContext, userID, chatID, userMessage)
|
memoryFuture := s.injectMemoryContext(requestCtx, conversationContext, userID, chatID, userMessage)
|
||||||
|
|
||||||
// 5.5 将前端传入的 thinkingMode 写入 CommonState,供 ChatNode 及下游节点读取。
|
// 6.5 将前端传入的 thinkingMode 写入 CommonState,供 ChatNode 及下游节点读取。
|
||||||
cs := runtimeState.EnsureCommonState()
|
cs := runtimeState.EnsureCommonState()
|
||||||
cs.ThinkingMode = thinkingMode
|
cs.ThinkingMode = thinkingMode
|
||||||
|
|
||||||
// 5.6 若 extra 携带 task_class_ids,校验后写入 CommonState(仅首轮/尚未设置时生效,跨轮持久化)。
|
// 6.6 若 extra 携带 task_class_ids,校验后写入 CommonState(仅首轮/尚未设置时生效,跨轮持久化)。
|
||||||
if taskClassIDs := readAgentExtraIntSlice(extra, "task_class_ids"); len(taskClassIDs) > 0 {
|
if taskClassIDs := readAgentExtraIntSlice(extra, "task_class_ids"); len(taskClassIDs) > 0 {
|
||||||
cs := runtimeState.EnsureCommonState()
|
cs := runtimeState.EnsureCommonState()
|
||||||
if len(cs.TaskClassIDs) == 0 {
|
if len(cs.TaskClassIDs) == 0 {
|
||||||
@@ -135,7 +144,7 @@ func (s *AgentService) runNewAgentGraph(
|
|||||||
|
|
||||||
cs = runtimeState.EnsureCommonState()
|
cs = runtimeState.EnsureCommonState()
|
||||||
|
|
||||||
// 5.7 先把本轮用户输入落库,确保后续可见 assistant 消息按真实时间线追加。
|
// 6.7 先把本轮用户输入落库,确保后续可见 assistant 消息按真实时间线追加。
|
||||||
userMsg := schema.UserMessage(userMessage)
|
userMsg := schema.UserMessage(userMessage)
|
||||||
if err := s.persistNewAgentConversationMessage(requestCtx, userID, chatID, userMsg, 0); err != nil {
|
if err := s.persistNewAgentConversationMessage(requestCtx, userID, chatID, userMsg, 0); err != nil {
|
||||||
pushErrNonBlocking(errChan, err)
|
pushErrNonBlocking(errChan, err)
|
||||||
@@ -158,7 +167,7 @@ func (s *AgentService) runNewAgentGraph(
|
|||||||
return s.persistNewAgentConversationMessage(persistCtx, userID, chatID, msg, 0)
|
return s.persistNewAgentConversationMessage(persistCtx, userID, chatID, msg, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 构造 AgentGraphRequest。
|
// 7. 构造 AgentGraphRequest。
|
||||||
var (
|
var (
|
||||||
confirmAction string
|
confirmAction string
|
||||||
resumeInteractionID string
|
resumeInteractionID string
|
||||||
@@ -175,16 +184,16 @@ func (s *AgentService) runNewAgentGraph(
|
|||||||
}
|
}
|
||||||
graphRequest.Normalize()
|
graphRequest.Normalize()
|
||||||
|
|
||||||
// 7. 适配 LLM clients(从 AIHub 的 ark.ChatModel 转换为 newAgent LLM Client)。
|
// 8. 适配 LLM clients(从 AIHub 的 ark.ChatModel 转换为 newAgent LLM Client)。
|
||||||
// 7.1 Chat/Deliver 使用 Pro 模型:路由分流、闲聊、交付总结属于标准复杂度。
|
// 8.1 Chat/Deliver 使用 Pro 模型:路由分流、闲聊、交付总结属于标准复杂度。
|
||||||
// 7.2 Plan/Execute 使用 Max 模型:规划和 ReAct 循环需要深度推理能力。
|
// 8.2 Plan/Execute 使用 Max 模型:规划和 ReAct 循环需要深度推理能力。
|
||||||
chatClient := infrallm.WrapArkClient(s.AIHub.Pro)
|
chatClient := infrallm.WrapArkClient(s.AIHub.Pro)
|
||||||
planClient := infrallm.WrapArkClient(s.AIHub.Max)
|
planClient := infrallm.WrapArkClient(s.AIHub.Max)
|
||||||
executeClient := infrallm.WrapArkClient(s.AIHub.Max)
|
executeClient := infrallm.WrapArkClient(s.AIHub.Max)
|
||||||
deliverClient := infrallm.WrapArkClient(s.AIHub.Pro)
|
deliverClient := infrallm.WrapArkClient(s.AIHub.Pro)
|
||||||
summaryClient := infrallm.WrapArkClient(s.AIHub.Lite)
|
summaryClient := infrallm.WrapArkClient(s.AIHub.Lite)
|
||||||
|
|
||||||
// 8. 适配 SSE emitter。
|
// 9. 适配 SSE emitter。
|
||||||
sseEmitter := newagentstream.NewSSEPayloadEmitter(outChan)
|
sseEmitter := newagentstream.NewSSEPayloadEmitter(outChan)
|
||||||
chunkEmitter := newagentstream.NewChunkEmitter(sseEmitter, traceID, resolvedModelName, requestStart.Unix())
|
chunkEmitter := newagentstream.NewChunkEmitter(sseEmitter, traceID, resolvedModelName, requestStart.Unix())
|
||||||
chunkEmitter.SetReasoningSummaryFunc(s.makeReasoningSummaryFunc(summaryClient))
|
chunkEmitter.SetReasoningSummaryFunc(s.makeReasoningSummaryFunc(summaryClient))
|
||||||
@@ -193,7 +202,7 @@ func (s *AgentService) runNewAgentGraph(
|
|||||||
s.persistNewAgentTimelineExtraEvent(context.Background(), userID, chatID, extra)
|
s.persistNewAgentTimelineExtraEvent(context.Background(), userID, chatID, extra)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 9. 构造 AgentGraphDeps(由 cmd/start.go 注入的依赖)。
|
// 10. 构造 AgentGraphDeps(由 cmd/start.go 注入的依赖)。
|
||||||
deps := newagentmodel.AgentGraphDeps{
|
deps := newagentmodel.AgentGraphDeps{
|
||||||
ChatClient: chatClient,
|
ChatClient: chatClient,
|
||||||
PlanClient: planClient,
|
PlanClient: planClient,
|
||||||
@@ -214,7 +223,7 @@ func (s *AgentService) runNewAgentGraph(
|
|||||||
QuickTaskDeps: s.quickTaskDeps,
|
QuickTaskDeps: s.quickTaskDeps,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. 构造 AgentGraphRunInput 并运行 graph。
|
// 11. 构造 AgentGraphRunInput 并运行 graph。
|
||||||
runInput := newagentmodel.AgentGraphRunInput{
|
runInput := newagentmodel.AgentGraphRunInput{
|
||||||
RuntimeState: runtimeState,
|
RuntimeState: runtimeState,
|
||||||
ConversationContext: conversationContext,
|
ConversationContext: conversationContext,
|
||||||
@@ -240,10 +249,10 @@ func (s *AgentService) runNewAgentGraph(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 11. 持久化聊天历史(用户消息 + 助手回复)。
|
// 12. 持久化聊天历史(用户消息 + 助手回复)。
|
||||||
requestTotalTokens := snapshotRequestTokenMeter(requestCtx).TotalTokens
|
requestTotalTokens := snapshotRequestTokenMeter(requestCtx).TotalTokens
|
||||||
s.adjustNewAgentRequestTokenUsage(requestCtx, userID, chatID, requestTotalTokens)
|
s.adjustNewAgentRequestTokenUsage(requestCtx, userID, chatID, requestTotalTokens)
|
||||||
// 11.5. 将最终状态快照异步写入 MySQL(通过 outbox)。
|
// 12.5. 将最终状态快照异步写入 MySQL(通过 outbox)。
|
||||||
// Deliver 节点已将快照保存到 Redis(2h TTL),此处通过 outbox 异步写入 MySQL 做永久存储。
|
// Deliver 节点已将快照保存到 Redis(2h TTL),此处通过 outbox 异步写入 MySQL 做永久存储。
|
||||||
if finalState != nil {
|
if finalState != nil {
|
||||||
snapshot := &newagentmodel.AgentStateSnapshot{
|
snapshot := &newagentmodel.AgentStateSnapshot{
|
||||||
@@ -253,7 +262,7 @@ func (s *AgentService) runNewAgentGraph(
|
|||||||
eventsvc.PublishAgentStateSnapshot(requestCtx, s.eventPublisher, snapshot, chatID, userID)
|
eventsvc.PublishAgentStateSnapshot(requestCtx, s.eventPublisher, snapshot, chatID, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 11.6. graph 完成后条件触发记忆抽取。
|
// 12.6. graph 完成后条件触发记忆抽取。
|
||||||
// 说明:
|
// 说明:
|
||||||
// 1. 只有本轮未走快捷随口记任务路径时才触发记忆抽取;
|
// 1. 只有本轮未走快捷随口记任务路径时才触发记忆抽取;
|
||||||
// 2. 避免随口记创建的 Task 与记忆系统产生语义冲突。
|
// 2. 避免随口记创建的 Task 与记忆系统产生语义冲突。
|
||||||
@@ -269,10 +278,10 @@ func (s *AgentService) runNewAgentGraph(
|
|||||||
// 排程预览缓存由 Deliver 节点负责写入(通过注入的 WriteSchedulePreview func),
|
// 排程预览缓存由 Deliver 节点负责写入(通过注入的 WriteSchedulePreview func),
|
||||||
// 保证只有任务真正完成时才写,中断路径不写中间态。
|
// 保证只有任务真正完成时才写,中断路径不写中间态。
|
||||||
|
|
||||||
// 12. 发送 OpenAI 兼容的流式结束标记,告知客户端 stream 已完成。
|
// 13. 发送 OpenAI 兼容的流式结束标记,告知客户端 stream 已完成。
|
||||||
_ = chunkEmitter.EmitDone()
|
_ = chunkEmitter.EmitDone()
|
||||||
|
|
||||||
// 13. 异步生成会话标题。
|
// 14. 异步生成会话标题。
|
||||||
s.ensureConversationTitleAsync(userID, chatID)
|
s.ensureConversationTitleAsync(userID, chatID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ func (s *AgentService) QueryTasksForTool(ctx context.Context, req newagentmodel.
|
|||||||
ID: task.ID,
|
ID: task.ID,
|
||||||
Title: task.Title,
|
Title: task.Title,
|
||||||
PriorityGroup: task.Priority,
|
PriorityGroup: task.Priority,
|
||||||
|
EstimatedSections: model.NormalizeEstimatedSections(&task.EstimatedSections),
|
||||||
IsCompleted: task.IsCompleted,
|
IsCompleted: task.IsCompleted,
|
||||||
DeadlineAt: task.DeadlineAt,
|
DeadlineAt: task.DeadlineAt,
|
||||||
UrgencyThresholdAt: task.UrgencyThresholdAt,
|
UrgencyThresholdAt: task.UrgencyThresholdAt,
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ func (p FeishuNotificationRequestedPayload) Validate() error {
|
|||||||
if targetURL == "" {
|
if targetURL == "" {
|
||||||
return errors.New("target_url 不能为空")
|
return errors.New("target_url 不能为空")
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(targetURL, "/schedule-adjust/") {
|
if !strings.HasPrefix(targetURL, "/assistant/") {
|
||||||
return errors.New("target_url 必须是 /schedule-adjust/{preview_id} 站内相对路径")
|
return errors.New("target_url 必须是 /assistant/{conversation_id} 站内相对路径")
|
||||||
}
|
}
|
||||||
if strings.Contains(targetURL, "://") || strings.HasPrefix(targetURL, "//") {
|
if strings.Contains(targetURL, "://") || strings.HasPrefix(targetURL, "//") {
|
||||||
return errors.New("target_url 不允许携带外部链接")
|
return errors.New("target_url 不允许携带外部链接")
|
||||||
|
|||||||
703
docs/backend/主动调度候选生成器讨论稿.md
Normal file
703
docs/backend/主动调度候选生成器讨论稿.md
Normal file
@@ -0,0 +1,703 @@
|
|||||||
|
# 主动调度缺口补全讨论稿
|
||||||
|
|
||||||
|
本文档由“主动调度候选生成器讨论稿”扩充而来,用于记录主动调度后续补全设计。
|
||||||
|
|
||||||
|
它不是实施计划,也不替代《第二阶段主动调度 MVP 实现方案》。这里主要保存三类内容:
|
||||||
|
|
||||||
|
1. 候选生成器、LLM 选择题和 `ask_user` 的设计共识。
|
||||||
|
2. 飞书通知进入聊天页后的主动调度合流方案。
|
||||||
|
3. 第四、第五阶段已经实现链路里仍然存在的空壳、占位和待验收点。
|
||||||
|
|
||||||
|
## 1. 当前实际链路
|
||||||
|
|
||||||
|
### 1.1 主动调度后端链路
|
||||||
|
|
||||||
|
当前主动调度主链路已经形成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
active_schedule.triggered
|
||||||
|
-> BuildContext
|
||||||
|
-> Observe
|
||||||
|
-> GenerateCandidates
|
||||||
|
-> CreatePreview
|
||||||
|
-> notification.feishu.requested
|
||||||
|
-> WebhookFeishuProvider
|
||||||
|
-> 用户回系统确认
|
||||||
|
-> ConfirmPreview
|
||||||
|
-> ApplyActiveScheduleChanges
|
||||||
|
```
|
||||||
|
|
||||||
|
这个链路的主体已经不是空壳:trigger、preview、notification、confirm apply、幂等、retry、api-only / worker-only / all 启动边界都已经被本地验收过。
|
||||||
|
|
||||||
|
这里必须把三件事拆开看,避免把“触发”和“通知”混成一条链:
|
||||||
|
|
||||||
|
1. 触发来源:后台 worker 自动触发、API 验收 / 后续产品内用户主动入口、`ask_user` 回复后的同步重跑。
|
||||||
|
2. 业务目标:`important_urgent_task` 负责把 task_pool 任务放进日程;`unfinished_feedback` 负责给已排动态任务新增补做块。
|
||||||
|
3. 投递方式:后台离线触达才需要飞书 webhook;用户已经在聊天页主动发起或回复 `ask_user` 时,直接通过 timeline / SSE 返回新 preview,不再先走飞书通知。
|
||||||
|
|
||||||
|
同一套 active scheduler graph 负责这两类业务目标,区别只在入口和投递方式,不是拆成两套逻辑。
|
||||||
|
|
||||||
|
### 1.2 当前没有真正做到的部分
|
||||||
|
|
||||||
|
当前仍然缺少这些关键能力:
|
||||||
|
|
||||||
|
1. `GenerateCandidates` 仍然是确定性 first-fit 候选生成,不是 topN 候选搜索器。
|
||||||
|
2. `Observation.Decision.LLMSelectionRequired=true` 已经写进结构,但没有真正的 LLM selector。
|
||||||
|
3. `CreatePreview` 明确写着“MVP 没有 LLM 选择器”,固定使用 `Candidates[0]`。
|
||||||
|
4. 本轮主动调度不再额外扩展独立评分子系统,候选裁决只保留轻量、可直接解释的维度。
|
||||||
|
5. memory 偏好没有进入 active scheduler 的候选裁决。
|
||||||
|
6. `ask_user` 在 active scheduler 里目前只是候选 / decision 类型,没有用户回复、重跑 graph、重新出结果的闭环。
|
||||||
|
7. 飞书通知只是离线自动触达,不能承接用户回复;用户主动补充、`ask_user` 回复和后续自由协作都必须回系统内聊天页完成。
|
||||||
|
8. 飞书 action_url 的当前目标已经收口到现有助手会话路由;前端仍在补适配,但不能继续依赖旧的 `/schedule-adjust/{preview_id}` 详情页口径。
|
||||||
|
|
||||||
|
## 2. 总体补全方向
|
||||||
|
|
||||||
|
### 2.1 候选生成器定位
|
||||||
|
|
||||||
|
候选生成器应该升级为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
安全候选工厂 + 维度评估器 + fallback 排序器
|
||||||
|
```
|
||||||
|
|
||||||
|
它负责:
|
||||||
|
|
||||||
|
1. 枚举合法候选。
|
||||||
|
2. 在只读事实快照上模拟候选实施后的局部结果。
|
||||||
|
3. 输出可解释的维度评估。
|
||||||
|
4. 给出后端 fallback 顺序。
|
||||||
|
5. 在无法安全生成候选时稳定降级为 `ask_user / notify_only / close`。
|
||||||
|
|
||||||
|
它不负责:
|
||||||
|
|
||||||
|
1. 结合 memory 做最终主观裁决。
|
||||||
|
2. 生成最终用户话术。
|
||||||
|
3. 绕过 preview 直接写正式日程。
|
||||||
|
4. 在事实不足时猜测用户偏好。
|
||||||
|
|
||||||
|
### 2.2 LLM 定位
|
||||||
|
|
||||||
|
LLM 不应该自由构造日程写库参数。
|
||||||
|
|
||||||
|
LLM 应该做:
|
||||||
|
|
||||||
|
1. 在后端给出的候选中选择一个。
|
||||||
|
2. 读取已注入的 memory 上下文、用户近期反馈、候选维度,做软裁决;这些信息不是 `ask_user` 要现场补采的内容。
|
||||||
|
3. 决定是否需要追问用户。
|
||||||
|
4. 输出面向用户的解释文案。
|
||||||
|
|
||||||
|
LLM 不应该做:
|
||||||
|
|
||||||
|
1. 自己发明 slot、event_id、task_id 或写库 change。
|
||||||
|
2. 修改 hard constraint 判断。
|
||||||
|
3. 在 `ask_user` 未完成时强行进入普通聊天链路。
|
||||||
|
|
||||||
|
### 2.3 newAgent / Eino 接入方向
|
||||||
|
|
||||||
|
当前倾向是把主动调度接到 newAgent 入口附近,而不是把主动调度塞进普通 ReAct 工具循环里。
|
||||||
|
|
||||||
|
建议边界:
|
||||||
|
|
||||||
|
1. active scheduler graph 仍然是独立业务 graph,负责事实读取、候选生成、preview、apply 边界。
|
||||||
|
2. newAgent 负责承接用户自由表达、现有 memory / execute 链路、页面交互和后续微调。
|
||||||
|
3. 短期在同一后端进程内同步调用 active scheduler service。
|
||||||
|
4. 后续拆微服务后,把 active scheduler graph 变成 RPC 同步调用。
|
||||||
|
5. newAgent 可以把“主动调度会话”作为上下文注入,但不能绕过 active preview / confirm API 直接写正式日程。
|
||||||
|
|
||||||
|
## 3. 六个候选生成讨论维度
|
||||||
|
|
||||||
|
### 3.1 候选生成器职责边界
|
||||||
|
|
||||||
|
已形成的结论:
|
||||||
|
|
||||||
|
1. 后端负责硬约束:归属校验、时间窗、容量、deadline、slot 合法性、候选 change 可执行性。
|
||||||
|
2. 后端负责 fallback 排序:LLM 失败、超时或输出非法时,使用后端 top1。
|
||||||
|
3. LLM 负责软裁决:已注入的 memory 偏好、近期反馈、风险取舍、用户可接受度。
|
||||||
|
4. 单个候选生成失败时丢弃该候选并写 trace,不让一个坏候选拖垮整轮。
|
||||||
|
5. 整体候选生成失败时按原因分流:
|
||||||
|
- 业务事实不足:`ask_user`。
|
||||||
|
- 没有安全候选:`notify_only` 或 `ask_user`。
|
||||||
|
- 系统异常:trigger failed,等待 worker retry 或人工排障。
|
||||||
|
|
||||||
|
待补全点:
|
||||||
|
|
||||||
|
1. 当前 `GenerateCandidates` 仍然是 first-fit,不是“枚举多个合法候选”。
|
||||||
|
2. 当前 `rankCandidates` 只是粗排序,没有稳定的维度评分。
|
||||||
|
3. 当前 `ask_user` 没有和用户回复闭环打通。
|
||||||
|
|
||||||
|
### 3.2 候选类型集合
|
||||||
|
|
||||||
|
当前已开放:
|
||||||
|
|
||||||
|
1. `add_task_pool_to_schedule`:把重要且紧急的 task_pool 任务加入滚动 24 小时空闲节次。
|
||||||
|
2. `create_makeup`:为未完成反馈新增补做块,不移动原任务。
|
||||||
|
3. `ask_user`:事实不足时追问。
|
||||||
|
4. `notify_only`:没有安全候选时只提醒。
|
||||||
|
5. `close`:触发条件已经失效时关闭。
|
||||||
|
|
||||||
|
当前明确关闭:
|
||||||
|
|
||||||
|
1. `compress_with_next_dynamic_task`:只保留 schema 和常量,第一版不生成。
|
||||||
|
2. 局部重排 / 多任务交换:还没有进入 active scheduler 候选集合。
|
||||||
|
|
||||||
|
建议下一轮补全:
|
||||||
|
|
||||||
|
1. `important_urgent_task` 不只取第一个连续空位,而是枚举 topN:
|
||||||
|
- 最早可用空位。
|
||||||
|
- deadline 前更稳的空位。
|
||||||
|
- 对用户偏好更友好的空位。
|
||||||
|
- 对日程节奏影响更低的空位。
|
||||||
|
2. `unfinished_feedback` 先解决“到底是哪条日程没完成”:
|
||||||
|
- 结合随口记上下文、当前时间和已排日程定位具体 schedule event。
|
||||||
|
- 定位成功后再生成补做候选;如果缺关键事实,直接进入 `ask_user`,不把比例推断作为主路径。
|
||||||
|
3. `compress_with_next_dynamic_task` 等候选必须先完成风险模型,再打开生成开关。
|
||||||
|
|
||||||
|
候选可编辑性建议:
|
||||||
|
|
||||||
|
```text
|
||||||
|
add_task_pool_to_schedule 可拖动时间,但不能改 target_id
|
||||||
|
create_makeup 可拖动时间,可调整补做节数上限
|
||||||
|
compress_with_next_dynamic_task 暂不开放
|
||||||
|
ask_user 不写日程,不可确认 apply
|
||||||
|
notify_only 不写日程,不可确认 apply
|
||||||
|
close 不写日程,不可确认 apply
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 评估维度与分数体系
|
||||||
|
|
||||||
|
当前倾向:先不做单一总分。
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
1. 主动调度早期更需要可解释,而不是一个看似精确的总分。
|
||||||
|
2. LLM 的决策价值要主动收窄:后端负责合法性、粗排和默认裁决,LLM 主要负责解释、接近候选间的有限裁决,以及信息不足时更自然地追问。
|
||||||
|
3. 后端 fallback 可以用简单稳定顺序,不需要过早引入复杂权重。
|
||||||
|
|
||||||
|
候选输出原则:
|
||||||
|
|
||||||
|
1. 未通过硬约束的方案不进入候选列表。
|
||||||
|
2. 硬约束失败只写入 trace / debug / invalid_reason,供排障使用。
|
||||||
|
3. 给 LLM 和用户看的候选维度,只保留需要取舍、比较或解释的信息。
|
||||||
|
|
||||||
|
候选建议输出维度:
|
||||||
|
|
||||||
|
```text
|
||||||
|
capacity_fit 是否满足 estimated_sections
|
||||||
|
risk_level low / medium / high
|
||||||
|
```
|
||||||
|
|
||||||
|
暂不保留的维度:
|
||||||
|
|
||||||
|
1. `explainability`:太像主观作文评分,后端不好稳定计算,LLM 也容易自证合理。
|
||||||
|
2. `reversibility`:当前主动调度第一版没有撤销按钮,贸然展示“可逆性”容易误导用户;后续如果实现 undo / rollback,再重新设计。
|
||||||
|
3. `disruption`:当前主动调度只生成“新增任务块 / 新增补做块”,不移动已有日程,扰动度几乎恒为 `none`,对候选选择没有区分度;等打开移动、压缩、局部重排候选时再恢复。
|
||||||
|
4. `deadline_fit`:deadline / urgency window 属于硬约束,不满足的方案不进入候选;满足后无需再作为展示维度。
|
||||||
|
5. `user_preference_fit`:memory / 用户近期反馈更适合交给 LLM 在选择题环节阅读和裁决,不伪装成后端可精确计算的评分。
|
||||||
|
6. `confidence`:不作为 LLM 可见候选维度。事实可信度只用于内部 trace 和 `ask_user` 门控;如果事实不足以支撑正式候选,就直接 `ask_user`,不要生成“低 confidence 候选”。
|
||||||
|
|
||||||
|
各维度的计算口径必须尽量简单:
|
||||||
|
|
||||||
|
1. `capacity_fit`:只看候选 slot 数是否覆盖 `estimated_sections`;如果容量不足则不进入候选,保留该字段主要用于区分“刚好够 / 有余量”。
|
||||||
|
2. `risk_level`:由 candidate_type、候选是否移动 / 压缩 / 重排已有日程、slot 稳定性和非致命 warning 汇总成低/中/高,不单独引入玄学评分。当前第一版只新增不移动,所以多数正式候选会是 `low`;后续接入粗排整体重排时,这个字段才会明显拉开差异。
|
||||||
|
|
||||||
|
内部 trace 可保留 `fact_confidence` 或 `evidence_level`,但它不进入 LLM 候选维度:
|
||||||
|
|
||||||
|
1. `high`:target 明确、`estimated_sections` 已落库、slot 来自确定课表空档、没有关键缺失信息。
|
||||||
|
2. `medium`:硬事实齐全,但部分定位来自推断或有非致命 warning。
|
||||||
|
3. `low`:核心目标不明确或缺关键事实;这种状态应转成 `ask_user`,不生成正式变更候选。
|
||||||
|
|
||||||
|
### 3.4 LLM 选择题协议
|
||||||
|
|
||||||
|
LLM 可见信息建议分两层:
|
||||||
|
|
||||||
|
1. 基础上下文:`trigger_type`、`target`、`time_window`、`missing_info`、`warnings`、`before/after` 摘要。
|
||||||
|
2. 候选层:`candidate_id`、`candidate_type`、`summary`、`preview_change`、`dimensions`,其中 `dimensions` 只包含 `capacity_fit / risk_level`。
|
||||||
|
3. 已有 memory 可以作为上下文注入,但不作为 `ask_user` 的缺失信息采集目标。
|
||||||
|
4. 不暴露原始全量事实快照,避免把后端内脏直接端给模型。
|
||||||
|
|
||||||
|
LLM 输入建议:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"trigger": {
|
||||||
|
"trigger_id": "ast_xxx",
|
||||||
|
"trigger_type": "important_urgent_task",
|
||||||
|
"target_type": "task_pool",
|
||||||
|
"target_id": 82
|
||||||
|
},
|
||||||
|
"context_summary": {
|
||||||
|
"window": "rolling_24h",
|
||||||
|
"missing_info": [],
|
||||||
|
"warnings": []
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"preferences": ["周末不想学习"],
|
||||||
|
"recent_feedback": ["晚上不适合高强度任务"]
|
||||||
|
},
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"candidate_id": "xxx",
|
||||||
|
"candidate_type": "add_task_pool_to_schedule",
|
||||||
|
"summary": "放到周四第3节",
|
||||||
|
"dimensions": {
|
||||||
|
"capacity_fit": "exact",
|
||||||
|
"risk_level": "low"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
LLM 输出必须限制为:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "select_candidate",
|
||||||
|
"selected_candidate_id": "xxx",
|
||||||
|
"reason": "选择这个候选的原因",
|
||||||
|
"user_message_summary": "给用户看的简短解释",
|
||||||
|
"ask_user_question": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
允许的 `action`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
select_candidate
|
||||||
|
ask_user
|
||||||
|
notify_only
|
||||||
|
close
|
||||||
|
```
|
||||||
|
|
||||||
|
兜底规则:
|
||||||
|
|
||||||
|
1. LLM 超时:使用后端 fallback candidate。
|
||||||
|
2. LLM 选了不存在的 candidate:重试一次;仍失败则 fallback。
|
||||||
|
3. LLM 输出 `ask_user` 但问题为空:后端用候选生成器给出的 missing_info 生成兜底问题。
|
||||||
|
4. LLM 输出任何写库参数:丢弃写库参数,只保留合法 action / candidate_id。
|
||||||
|
|
||||||
|
### 3.5 未完成反馈补做链路
|
||||||
|
|
||||||
|
当前状态:
|
||||||
|
|
||||||
|
1. 若 trigger 直接携带 `schedule_event` target,后端可以定位未完成对象。
|
||||||
|
2. 若无法定位目标,observe 会降级为 `ask_user`。
|
||||||
|
3. 第一版补做只生成新补做块,不移动原任务。
|
||||||
|
|
||||||
|
需要补全:
|
||||||
|
|
||||||
|
1. 随口记链路要能把“我这个没做完”定位到具体 schedule event。
|
||||||
|
2. 定位优先靠 LLM 上下文推断 + 当前时间 + 已排日程窗口;定位不稳时直接 `ask_user` 问“是哪一条没做完”。
|
||||||
|
3. 补做节数最好前置到 task 写入环节,由 `tasks.estimated_sections` 承接;当前 `model.Task` 里已经有这个字段,主动调度也会消费它,但普通任务创建接口和 quick task 入口还没完全透传,所以现在仍要保留 `1` 的兜底。
|
||||||
|
4. `ask_user` 追问不要设计成固定问法,而是由 `missing_info` 驱动,缺什么就问什么。
|
||||||
|
5. 用户在聊天页说“我周末不想学习”这类话时,如果已经解除主动调度锁定,就直接进入现有 newAgent memory / execute 链路,不回到主动调度 graph。
|
||||||
|
|
||||||
|
### 3.6 压缩融合与局部重排
|
||||||
|
|
||||||
|
当前结论:
|
||||||
|
|
||||||
|
1. `compress_with_next_dynamic_task` 第一版继续关闭。
|
||||||
|
2. 打开前必须先解决候选安全性和风险解释。
|
||||||
|
3. 压缩融合不能由 LLM 自由生成,必须由后端模拟和校验。
|
||||||
|
4. 压缩融合只允许“同一任务链路内部消化”,不把 A 任务压进无关 B 任务。
|
||||||
|
|
||||||
|
压缩融合的业务定义:
|
||||||
|
|
||||||
|
```text
|
||||||
|
谁污染谁治理:哪个任务发生未完成/超时问题,就只在这个任务自己的后继块里消化。
|
||||||
|
```
|
||||||
|
|
||||||
|
换句话说:
|
||||||
|
|
||||||
|
1. 如果 A 任务没完成,只允许把 A 的剩余内容压缩进 A 的后继任务块。
|
||||||
|
2. 不允许因为 B 看起来空余,就把 A 的剩余内容塞进 B。
|
||||||
|
3. 如果 B 真的长期空余,那是 B 自己的估时或安排问题,不应该用来替 A 兜底。
|
||||||
|
4. 这样可以避免主动调度把问题跨任务传染,保持候选解释简单、责任边界清楚。
|
||||||
|
|
||||||
|
打开条件建议:
|
||||||
|
|
||||||
|
1. 只处理同一任务自己的动态后继块,不压缩固定课程、外部事件或其它任务。
|
||||||
|
2. 被压缩任务必须保留最小节数,不能为了补救把后继块压到失真。
|
||||||
|
3. 不额外引入抽象风险评分;压缩融合的主要风险不是抽象节奏分,而是这个任务自身仍然完不成。
|
||||||
|
4. 用户确认页必须清楚展示“哪个任务自己的后继块被压缩、从几节压到几节、腾出的时间补哪里”。
|
||||||
|
5. 压缩失败时优先 `notify_only`,事实不足才 `ask_user`。
|
||||||
|
|
||||||
|
压缩融合的风险口径:
|
||||||
|
|
||||||
|
1. 该任务自己的后继块被压缩后,任务整体仍可能完不成。
|
||||||
|
2. 如果后继块被压得过短,可能需要继续追加补做,而不是继续污染其它任务。
|
||||||
|
3. 用户可能不接受压缩某类自有后继块,例如复习、运动或休息。
|
||||||
|
4. 除上述情况外,不额外引入抽象 health 风险。
|
||||||
|
|
||||||
|
## 4. 主动调度进入聊天页的合流设计
|
||||||
|
|
||||||
|
### 4.1 为什么不做孤立表单页
|
||||||
|
|
||||||
|
飞书 webhook 是单向通知,不能指望飞书直接承接 `ask_user` 回复。
|
||||||
|
|
||||||
|
既然用户必须回系统内回复,就应该复用聊天页:
|
||||||
|
|
||||||
|
1. 复用 newAgent 的自由表达能力。
|
||||||
|
2. 复用已有日程预览、微调、确认、自动拖拽保存等体验。
|
||||||
|
3. 用户在主动调度解锁后可以直接说“我周末不想学习”,这类偏好由现有 newAgent memory / execute 链路处理,不在主动调度里单独新建记忆链路。
|
||||||
|
4. 避免做一个只能补字段的孤立页面。
|
||||||
|
|
||||||
|
### 4.2 两种进入聊天页状态
|
||||||
|
|
||||||
|
信息完整:
|
||||||
|
|
||||||
|
```text
|
||||||
|
飞书点击
|
||||||
|
-> 打开聊天页
|
||||||
|
-> 加载 active preview / trigger 上下文
|
||||||
|
-> 展示主动调度建议卡片
|
||||||
|
-> 用户可确认、拖动微调、提出异议
|
||||||
|
-> 正常进入 newAgent 自由链路
|
||||||
|
```
|
||||||
|
|
||||||
|
信息不完整:
|
||||||
|
|
||||||
|
```text
|
||||||
|
飞书点击
|
||||||
|
-> 打开聊天页
|
||||||
|
-> 进入主动调度 ask_user 锁定态
|
||||||
|
-> 用户回复缺失信息
|
||||||
|
-> 后端更新事实 / memory
|
||||||
|
-> 重跑 active scheduler graph
|
||||||
|
-> 生成新的 preview
|
||||||
|
-> 解除锁定,回到正常聊天链路
|
||||||
|
```
|
||||||
|
|
||||||
|
关键约束:
|
||||||
|
|
||||||
|
1. `ask_user` 未完成前,不允许直接进入普通聊天链路。
|
||||||
|
2. 用户回复不是普通闲聊,而是当前主动调度 session 的补信息输入。
|
||||||
|
3. 补信息后必须重跑 active scheduler graph,而不是拿旧候选硬套。
|
||||||
|
4. 新结果出来后,才允许 Agent 基于新 preview 继续自由协作。
|
||||||
|
5. 聊天消息仍然正常写入 conversation / timeline;锁定的是后端路由控制权,不是聊天记录写入。
|
||||||
|
|
||||||
|
### 4.3 主动调度会话抽象
|
||||||
|
|
||||||
|
已拍板:单独新增 `active_schedule_sessions`,不把主动调度状态塞进现有 conversation 表,也不只在 `active_schedule_previews` 上加 `conversation_id`。
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
1. 主动调度先于聊天发生:后台 trigger / preview 发出时,用户可能还没有打开聊天页;但在飞书通知真正发出前,后端会预创建或绑定 `conversation_id`,让最终入口落到现有会话路由。
|
||||||
|
2. 主动调度和聊天生命周期不同:preview 会过期、重跑、确认、忽略;conversation 只是承载用户可见对话。
|
||||||
|
3. `ask_user` 的恢复语义不同:active scheduler 收到回复后只补当前缺失业务事实并重跑 graph,而不是恢复 newAgent 的 plan / execute 节点,也不新建 memory 写入链路。
|
||||||
|
4. 审计链路需要串起 `trigger_id -> preview_id -> notification_id -> conversation_id -> apply_id`,单独 session 更清楚。
|
||||||
|
5. 一个聊天会话后续可以继续讨论同一个主动调度 preview,但这不代表主动调度一直拥有聊天路由管辖权。
|
||||||
|
|
||||||
|
最小需要保存:
|
||||||
|
|
||||||
|
```text
|
||||||
|
session_id
|
||||||
|
user_id
|
||||||
|
conversation_id # 创建时可空,通知前必须绑定
|
||||||
|
trigger_id
|
||||||
|
current_preview_id
|
||||||
|
status
|
||||||
|
state_json # 轻量业务状态:pending_question / missing_info / last_candidate_id / last_notification_id / expires_at / failed_reason 等
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
状态建议:
|
||||||
|
|
||||||
|
```text
|
||||||
|
waiting_user_reply 等待用户补信息
|
||||||
|
rerunning 已收到回复,正在重跑 graph
|
||||||
|
ready_preview 已有可展示 preview,已解除硬拦截
|
||||||
|
applied 用户已确认应用
|
||||||
|
ignored 用户忽略
|
||||||
|
expired 会话过期
|
||||||
|
failed 重跑或绑定失败
|
||||||
|
```
|
||||||
|
|
||||||
|
飞书入口已拍板:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/assistant/{conversation_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
不使用 `/assistant?active_preview_id=xxx&trigger_id=xxx` 作为主入口。`preview_id / trigger_id` 由后端通过 session 查询,避免前端 URL 长期承担业务状态拼装。
|
||||||
|
|
||||||
|
`ask_user` pending 已拍板:
|
||||||
|
|
||||||
|
1. 不复用 newAgent `PendingInteraction` 作为状态源。
|
||||||
|
2. active scheduler 的 pending 放在 `active_schedule_sessions` 里管理。
|
||||||
|
3. newAgent `PendingInteraction` 可以借鉴交互协议和 UI 体验,但不能决定主动调度 graph 如何恢复。
|
||||||
|
|
||||||
|
### 4.4 聊天管辖边界
|
||||||
|
|
||||||
|
主动调度 session 和聊天 timeline 是两本账:
|
||||||
|
|
||||||
|
```text
|
||||||
|
conversation / timeline:
|
||||||
|
记录用户和 AI 看得见的对话内容。
|
||||||
|
|
||||||
|
active_schedule_sessions:
|
||||||
|
记录这段对话对主动调度流程意味着什么状态变化。
|
||||||
|
```
|
||||||
|
|
||||||
|
因此,在 `waiting_user_reply / rerunning` 阶段会出现“双写”,但不是重复保存同一份消息:
|
||||||
|
|
||||||
|
1. timeline 写入用户可见消息。
|
||||||
|
2. session 写入主动调度状态流转。
|
||||||
|
3. 用户正在聊天页等待新方案时,session 推进、graph 重跑、preview regenerated 必须走当前请求内同步 / SSE 主路径,不能只丢 outbox 后结束请求。
|
||||||
|
|
||||||
|
路由管辖边界:
|
||||||
|
|
||||||
|
```text
|
||||||
|
waiting_user_reply / rerunning:
|
||||||
|
active_schedule_sessions 有路由管辖权。
|
||||||
|
用户输入不进入普通 newAgent,先用于补信息、重跑主动调度;偏好类表达留到解除锁定后再走 newAgent 的 memory / execute 链路。
|
||||||
|
|
||||||
|
ready_preview:
|
||||||
|
解除硬管辖。
|
||||||
|
用户输入进入普通 newAgent,但请求里可以携带 active_session 上下文。
|
||||||
|
|
||||||
|
applied / ignored / expired / failed:
|
||||||
|
session 只做审计和历史引用,不再拦截后续聊天。
|
||||||
|
```
|
||||||
|
|
||||||
|
也就是说:
|
||||||
|
|
||||||
|
1. `active pending` 只表示是否拦截聊天输入。
|
||||||
|
2. `active context` 表示是否把这次主动调度 preview / session 注入给 newAgent 参考。
|
||||||
|
3. pending 结束后,context 可以继续存在,但它没有路由管辖权。
|
||||||
|
|
||||||
|
session 表的 outbox 口径:
|
||||||
|
|
||||||
|
1. 第一版不新增 `active_schedule.session.reply_received` 作为主驱动事件。
|
||||||
|
2. 用户补信息后的主流程必须同步完成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
写入 timeline
|
||||||
|
-> 更新 session.status = rerunning
|
||||||
|
-> 解析补充信息 / 更新本轮上下文
|
||||||
|
-> 重跑 active scheduler graph
|
||||||
|
-> 生成新 preview
|
||||||
|
-> SSE 推送新方案卡片
|
||||||
|
-> 更新 session.status = ready_preview
|
||||||
|
```
|
||||||
|
|
||||||
|
3. outbox 只用于已有异步副作用或兜底可靠性,例如:
|
||||||
|
- timeline 持久化。
|
||||||
|
- memory 长期抽取 / 写入。
|
||||||
|
- agent 状态快照。
|
||||||
|
- notification 投递。
|
||||||
|
- 同步处理失败后的 failed 审计或后续人工重放。
|
||||||
|
4. session 也要接入缓存链路:chat 路由按 `user_id + conversation_id` 先查 session 热缓存,miss 再回源 DB,并在 `status / current_preview_id / conversation_id` 变化后同步回填缓存。
|
||||||
|
5. 构造用户消息时,要把 `active_schedule_sessions + preview + pending_question` 组装成可直接复用的消息快照并写入缓存,避免每次都从 DB 重组同一份主动调度上下文。
|
||||||
|
6. session 创建、绑定 `conversation_id`、释放路由管辖权都优先同步写表,不单独事件化。
|
||||||
|
7. 后续如果要补完整审计,可以再考虑 session event log,但不作为 MVP 主链路依赖。
|
||||||
|
|
||||||
|
### 4.5 前端需要补的适配层
|
||||||
|
|
||||||
|
当前 `AssistantPanel.vue` 已有:
|
||||||
|
|
||||||
|
1. SSE `extra` 事件处理。
|
||||||
|
2. `confirm_request` 覆盖层。
|
||||||
|
3. `schedule_completed` 卡片。
|
||||||
|
4. `ScheduleResultCard`。
|
||||||
|
5. `ScheduleFineTuneModal`。
|
||||||
|
6. `extra.resume` 发送协议。
|
||||||
|
|
||||||
|
但它现在缺少:
|
||||||
|
|
||||||
|
1. 从 URL / route query 初始化 active schedule session。
|
||||||
|
2. 拉取 `GET /api/v1/active-schedule/preview/:preview_id`。
|
||||||
|
3. 把 `ActiveSchedulePreviewDetail` 转成前端可展示的主动调度卡片。
|
||||||
|
4. 主动调度 preview 和 newAgent `SchedulePreviewData` 的 DTO 适配边界。
|
||||||
|
5. `ask_user` 锁定态输入框提示和发送协议。
|
||||||
|
6. 主动调度 confirm 应调用 `/active-schedule/preview/:preview_id/confirm`,不能走 newAgent 普通 schedule preview 保存接口。
|
||||||
|
|
||||||
|
### 4.6 后端需要补的合流点
|
||||||
|
|
||||||
|
后端需要补:
|
||||||
|
|
||||||
|
1. 飞书 action_url 指向现有聊天页,而不是不存在的 `/schedule-adjust/{preview_id}`。
|
||||||
|
2. `notification.FeishuNotificationRequestedPayload.Validate` 不应长期硬编码 `/schedule-adjust/`,应允许 `/assistant/{conversation_id}`。
|
||||||
|
3. active scheduler session 需要能在通知前绑定 `conversation_id`。
|
||||||
|
4. newAgent 入口需要识别 active scheduler 上下文:
|
||||||
|
- 信息完整:注入 preview / candidate / constraints。
|
||||||
|
- 等待补信息:拦截普通聊天,先完成 active `ask_user`。
|
||||||
|
5. 用户在聊天中给出的偏好,如果已经处于自由聊天链路,就交给现有 newAgent memory / execute 处理;只有主动调度所需的缺失事实才会让 session 继续占管。
|
||||||
|
|
||||||
|
### 4.7 与现有 execute 链路的接缝
|
||||||
|
|
||||||
|
主动调度不复制 `task_item`,也不另起一套排程写入链路。
|
||||||
|
|
||||||
|
最小接法:
|
||||||
|
|
||||||
|
1. 主动调度只负责在会话层生成 preview 和确认态,不直接改正式日程。
|
||||||
|
2. 前端拖拽和确认继续复用现有会话快照语义,后端只更新同一份 `ScheduleState`。
|
||||||
|
3. 确认通过后,再把这份状态的 diff 落成正式 `schedule_events / schedules`。
|
||||||
|
4. 会话释放后,普通聊天回到 newAgent `execute`,继续在同一份 `ScheduleState / OriginalScheduleState` 上做 `move / swap / place / unplace`。
|
||||||
|
|
||||||
|
所以新增量是 session、路由拦截、preview DTO 适配和 graph 裁决,不是新建排程引擎,也不是把待调整任务复制成新任务再排一遍。
|
||||||
|
|
||||||
|
## 5. 飞书 Webhook 与消息拼装
|
||||||
|
|
||||||
|
当前真实飞书走“用户级 Webhook 触发器”,后端向用户配置的 webhook POST 极简业务 JSON。
|
||||||
|
|
||||||
|
当前 payload 形态可以保留:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "smartflow.schedule_adjustment_ready",
|
||||||
|
"version": "1",
|
||||||
|
"notification_id": 123,
|
||||||
|
"user_id": 5,
|
||||||
|
"preview_id": "asp_xxx",
|
||||||
|
"trigger_id": "ast_xxx",
|
||||||
|
"trigger_type": "important_urgent_task",
|
||||||
|
"target_type": "task_pool",
|
||||||
|
"target_id": 81,
|
||||||
|
"message": {
|
||||||
|
"title": "SmartFlow 日程调整建议",
|
||||||
|
"summary": "把重要且紧急任务放入滚动 24 小时内的空闲节次。",
|
||||||
|
"action_text": "查看并确认调整",
|
||||||
|
"action_url": "http://localhost:5173/assistant/conv_xxx"
|
||||||
|
},
|
||||||
|
"trace_id": "trace_xxx",
|
||||||
|
"sent_at": "2026-04-30T17:34:52+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
消息拼装原则:
|
||||||
|
|
||||||
|
1. 飞书流程只需要读 `message.title / summary / action_text / action_url`。
|
||||||
|
2. 业务分支读顶层 `event / version / trigger_type / preview_id / trigger_id`。
|
||||||
|
3. 不把复杂卡片协议塞进 webhook payload。
|
||||||
|
4. 私聊、群聊、机器人卡片由飞书流程自行编排。
|
||||||
|
5. 本地开发用 `notification.frontendBaseURL=http://localhost:5173`。
|
||||||
|
6. 未上线前,示例配置也应写 localhost;上线后再替换正式域名。
|
||||||
|
|
||||||
|
## 6. 第四、第五阶段链路扫点
|
||||||
|
|
||||||
|
### 6.1 已经比较实的部分
|
||||||
|
|
||||||
|
1. `active_schedule.triggered` handler 已能消费 outbox 并推进 trigger 状态。
|
||||||
|
2. due job scanner 已能把到期 job 转成正式 trigger。
|
||||||
|
3. preview 写入、详情查询、confirm apply 已有完整后端链路。
|
||||||
|
4. notification service 已有记录表、状态机、去重、retry、dead/skipped 分类。
|
||||||
|
5. WebhookFeishuProvider 已经接用户级 webhook 配置,并能真实 POST 飞书 webhook。
|
||||||
|
6. API-only / worker-only / all 启动边界已经验过。
|
||||||
|
7. `important_urgent_task` 和 `unfinished_feedback` 的基础端到端已经跑通。
|
||||||
|
|
||||||
|
### 6.2 仍是空壳或半空壳的部分
|
||||||
|
|
||||||
|
1. LLM selector 空壳:
|
||||||
|
- `LLMSelectionRequired=true` 只是标志。
|
||||||
|
- 没有选择题 prompt、模型调用、解析、重试和 fallback 分支。
|
||||||
|
2. LLM summary 空壳:
|
||||||
|
- notification summary 当前来自 selected candidate summary。
|
||||||
|
- 没有真正“LLM 生成通知摘要,模板兜底”的实现。
|
||||||
|
3. 候选搜索空壳:
|
||||||
|
- 当前基本是 first-fit top1。
|
||||||
|
- 没有 topN、维度评分、health before/after。
|
||||||
|
4. memory 接入空壳:
|
||||||
|
- active scheduler 不读取 memory。
|
||||||
|
- 用户偏好只会在 newAgent 普通聊天链路里发挥作用。
|
||||||
|
5. `ask_user` 闭环空壳:
|
||||||
|
- active scheduler 能产出 `ask_user` 类型,但没有用户回复入口、session、重跑 graph。
|
||||||
|
6. 聊天页合流空壳:
|
||||||
|
- 前端没有 active preview route/query 初始化。
|
||||||
|
- 没有主动调度 preview DTO 到聊天卡片 / 微调弹窗的适配。
|
||||||
|
7. 飞书 action_url / 校验口径待收口:
|
||||||
|
- 主入口已经统一到 `/assistant/{conversation_id}`,不再把 `/schedule-adjust/{preview_id}` 当成新目标。
|
||||||
|
- 后端仍要保证在发通知前能从 session 生成最终会话链接。
|
||||||
|
- shared event 校验和前端适配仍需继续收尾,不能把旧路径当成主链路。
|
||||||
|
8. 未完成反馈自然语言定位空壳:
|
||||||
|
- 当前更依赖 trigger 直接给 `schedule_event` target。
|
||||||
|
- 随口记“哪个没做完”的定位链路还没接。
|
||||||
|
9. `unfinished_feedback` 补做节数口径仍粗:
|
||||||
|
- 比例推断不再作为当前主路径。
|
||||||
|
- 当前更应先补“定位目标 + 必要时 ask_user”,补做节数先按原任务长度、用户明确剩余内容和可用容量保守估算。
|
||||||
|
10. 压缩融合空壳:
|
||||||
|
- schema / 常量存在。
|
||||||
|
- 生成逻辑明确关闭。
|
||||||
|
11. 前端主动调度确认体验空壳:
|
||||||
|
- 后端 confirm API 存在。
|
||||||
|
- 但聊天页还没把主动调度卡片的确认按钮接到该 API。
|
||||||
|
12. 剩余验收缺口:
|
||||||
|
- confirm apply 冲突失败。
|
||||||
|
- preview 过期拒绝。
|
||||||
|
- 更系统的失败注入脚本化。
|
||||||
|
- 测试数据隔离策略。
|
||||||
|
|
||||||
|
### 6.3 不是空壳,但需要改口径的地方
|
||||||
|
|
||||||
|
1. 当前实施口径已经统一为“进入聊天页承接主动调度会话”;文档或历史记录里若出现 `/schedule-adjust/{preview_id}`,只能作为旧口径残留,不能作为新实现目标。
|
||||||
|
2. 通知事件 `target_url` 的校验规则要允许站内聊天页路径,主入口使用 `/assistant/{conversation_id}`,不要再新增独立 schedule-adjust 页面。
|
||||||
|
3. 前端 `ScheduleFineTuneModal` 当前绑定 newAgent preview 数据结构,不能直接复用 active preview DTO,必须加适配层。
|
||||||
|
|
||||||
|
### 6.4 本轮前置条件:estimated_sections 写入链路补齐
|
||||||
|
|
||||||
|
这件事属于任务创建 / 随口记写入链路,不属于 active scheduler graph 内部逻辑;但它是本轮缺口补齐方案的前置条件,应该单独拎出来做。
|
||||||
|
|
||||||
|
目标口径:
|
||||||
|
|
||||||
|
1. LLM 在创建 task_pool 任务时,除了 title、priority、deadline、urgency_threshold_at,也要给出预计占用节数。
|
||||||
|
2. 预计占用节数写入 `tasks.estimated_sections`,范围仍按 MVP 约定限制为 1~4。
|
||||||
|
3. 主动调度只消费 `tasks.estimated_sections`,不在调度阶段重新推断任务复杂度。
|
||||||
|
4. 如果 LLM 没给、解析失败或超出范围,写入链路兜底为 1 节,并记录 trace / warning,避免阻断任务创建。
|
||||||
|
|
||||||
|
当前代码现状:
|
||||||
|
|
||||||
|
1. `model.Task` 已经有 `EstimatedSections` 字段。
|
||||||
|
2. active scheduler 已经读取并消费该字段。
|
||||||
|
3. 普通任务创建请求 `UserAddTaskRequest`、转换层和 quick task 创建入口还没完全透传该字段。
|
||||||
|
|
||||||
|
本轮建议改造点:
|
||||||
|
|
||||||
|
1. 给 `UserAddTaskRequest` 增加 `estimated_sections` 入参,并在转换层写入 `model.Task.EstimatedSections`。
|
||||||
|
2. 给 quick task 创建依赖增加 estimated sections 参数,让 newAgent / 随口记创建任务时能把 LLM 判断结果带进 DB。
|
||||||
|
3. 对入口值做 1~4 的统一归一化,避免每条写入链路各自截断。
|
||||||
|
4. 更新相关响应或查询 DTO 时,至少让调试 / 验收能看到任务最终写入的 estimated sections。
|
||||||
|
|
||||||
|
## 7. 下一轮讨论顺序
|
||||||
|
|
||||||
|
上面两项只是聊天页合流和验收尾项,不代表完整实施顺序。完整开工顺序必须先把主动调度 graph 补回来,否则只是给当前固定 pipeline 包一层 UI。
|
||||||
|
|
||||||
|
完整实施顺序建议:
|
||||||
|
|
||||||
|
1. 先补 `estimated_sections` 写入入口,保证 task 创建 / 随口记创建任务时已经带预计节数。
|
||||||
|
2. 补主动调度 Eino graph:把现有 `BuildContext -> Observe -> GenerateCandidates -> CreatePreview` 包成可继续扩展的 graph,并新增 LLM 选择题 / ask_user 分支。
|
||||||
|
3. 升级候选生成与裁决:从 first-fit top1 走向 topN 候选、维度信息、memory 输入和后端 fallback。
|
||||||
|
4. 补 `active_schedule_sessions` 和聊天入口拦截,让 `ask_user` 回复能同步重跑 graph。
|
||||||
|
5. 补 active preview 到聊天页卡片 / 微调弹窗的前端 DTO 适配。
|
||||||
|
6. 跑第五阶段剩余验收项:冲突、过期、失败注入脚本化。
|
||||||
|
|
||||||
|
对应到《第二阶段主动调度 MVP 实现方案》里,1 属于本轮前置条件,2-4 属于第六阶段主动调度 graph 与会话桥,5 属于第六阶段聊天页适配,6 属于第十四章剩余验收。
|
||||||
|
|
||||||
|
`unfinished_feedback` 的主口径也已经基本拍板,不再作为下一轮主讨论项:
|
||||||
|
|
||||||
|
1. 定位靠 LLM 上下文推断 + 当前时间 + 已排日程窗口。
|
||||||
|
2. 定位不稳就 `ask_user`,由 `missing_info` 决定缺什么问什么。
|
||||||
|
3. 定位成功后生成补做 preview,不直接移动原任务。
|
||||||
|
|
||||||
|
`estimated_sections` 写入入口补齐是本轮前置条件,见 6.4;它单独属于任务创建 / 随口记写入链路,不混进主动调度 graph 设计里。
|
||||||
|
|
||||||
|
`waiting_user_reply / rerunning` 阻塞态的聊天入口拦截协议已经拍板:
|
||||||
|
|
||||||
|
1. 后端在 chat 路由按 `active_schedule_sessions` 状态拦截。
|
||||||
|
2. `waiting_user_reply / rerunning` 时,用户消息先进入主动调度补信息链路,不进入普通 newAgent 自由聊天。
|
||||||
|
3. `ready_preview / applied / ignored / expired / failed` 才释放回普通聊天链路。
|
||||||
|
4. 这一块后续只补接口返回码、错误提示和前端文案,不再作为下一轮主讨论项。
|
||||||
|
|
||||||
|
`estimated_sections` 写入入口补齐优先补 `UserAddTaskRequest` 和 quick task 创建入口对该字段的透传,不要让主动调度侧重新猜一遍。
|
||||||
|
|
||||||
|
已基本定稿、不再占用下一轮主讨论的内容:
|
||||||
|
|
||||||
|
1. `active_schedule_sessions` 表字段和同步状态机,当前先按极简版 `session_id / user_id / conversation_id / trigger_id / current_preview_id / status / state_json / created_at / updated_at` 落地。
|
||||||
|
2. 候选公开维度字段,当前先按 `capacity_fit / risk_level` 执行;事实可信度只作为内部 trace / `ask_user` 门控,不进入 LLM 候选维度。
|
||||||
|
3. LLM 选择题 JSON 协议,当前先按 `action / selected_candidate_id / reason / user_message_summary / ask_user_question` 执行。
|
||||||
|
|
||||||
|
如果你想继续把某一项再收紧,优先补拍板:
|
||||||
|
|
||||||
|
1. `state_json` 里面到底要不要再拆成独立列。
|
||||||
|
2. `ask_user_question` 是否必须非空。
|
||||||
|
3. `reason` 是给内部调试看,还是也要直接展示给用户。
|
||||||
445
docs/backend/主动调度缺口分阶段实施计划.md
Normal file
445
docs/backend/主动调度缺口分阶段实施计划.md
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
# 主动调度缺口分阶段实施计划
|
||||||
|
|
||||||
|
本文档用于把《第二阶段主动调度 MVP 实现方案.md》《主动调度候选生成器讨论稿.md》和当前代码仓库的实际状态收口到一份可执行的推进计划里。
|
||||||
|
|
||||||
|
目标只有一个:把主动调度剩下的缺口按阶段补完,并且每个阶段都能明确验收、明确自动化边界、明确是否已经完成。后续我会在这里持续把 `[ ]` 改成 `[x]`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 当前仓库基线
|
||||||
|
|
||||||
|
先把现在已经有的和还缺的分开,避免后面阶段定义漂移。
|
||||||
|
|
||||||
|
### 已经落地的基座
|
||||||
|
|
||||||
|
- [x] `backend/active_scheduler` 已经形成准独立模块,包含 `context / observe / candidate / preview / apply / service / job` 等目录。
|
||||||
|
- [x] `dry-run -> trigger -> preview -> confirm -> apply` 主链路已经存在。
|
||||||
|
- [x] `active_schedule.triggered`、`notification.feishu.requested`、`notification_records`、用户级飞书 webhook 配置接口已经打通。
|
||||||
|
- [x] `active_schedule_previews`、`schedule_events.task_source_type / makeup_for_event_id / active_preview_id`、`tasks.estimated_sections` 这些模型层字段已经存在。
|
||||||
|
- [x] `api / worker / all` 三种启动边界已经有实测基础。
|
||||||
|
- [x] `important_urgent_task`、`unfinished_feedback` 的主触发链路已经跑过一轮端到端。
|
||||||
|
|
||||||
|
### 近期缺口收口状态
|
||||||
|
|
||||||
|
- [x] `UserAddTaskRequest`、转换层、quick task / 随口记创建入口已完整透传 `estimated_sections`。
|
||||||
|
- [x] `CreatePreview` 已切到 graph + 受限 selector,不再是固定 top1 / `Candidates[0]`。
|
||||||
|
- [x] `active_schedule_sessions` 已正式进入代码,并接好缓存链路。
|
||||||
|
- [x] 聊天入口已按 session 状态拦截,`waiting_user_reply / rerunning` 会接管补信息链路。
|
||||||
|
- [ ] `unfinished_feedback` 的“定位 -> ask_user -> 重跑 graph”闭环还没完全做实。
|
||||||
|
- [ ] 聊天页里的主动调度 preview 卡片 / 微调弹窗还没有最小适配。
|
||||||
|
- [ ] 剩余极限验收项还没完全脚本化。
|
||||||
|
|
||||||
|
### 代码锚点
|
||||||
|
|
||||||
|
后续实施时优先看这些位置:
|
||||||
|
|
||||||
|
- `backend/model/task.go`:`Task.EstimatedSections` 已存在,普通创建请求已接入 `estimated_sections`。
|
||||||
|
- `backend/conv/task.go`:任务创建请求转模型时已透传预计节数。
|
||||||
|
- `backend/cmd/start.go`:quick task 创建依赖已透传预计节数;主动调度 graph runner / LLM selector / session rerun 也已在启动期装配。
|
||||||
|
- `backend/active_scheduler/preview/service.go`:preview 已支持 `SelectedCandidateID / ExplanationText / NotificationSummary / FallbackUsed`。
|
||||||
|
- `backend/active_scheduler/graph`:阶段 1 graph runner 已落地;`backend/active_scheduler/selection` 承载 LLM selector / prompt / DTO。
|
||||||
|
- `backend/active_scheduler/service/trigger_pipeline.go`:trigger workflow 已调用 graph result,再写 preview 和 notification。
|
||||||
|
- `backend/service/agentsvc/agent_newagent.go`、`backend/service/agentsvc/agent_active_schedule_session.go`、`backend/api/agent.go`:聊天入口和 graph 执行边界已接 session 管辖。
|
||||||
|
- `frontend/src/components/dashboard/AssistantPanel.vue`:主动调度卡片与确认按钮做最小分支。
|
||||||
|
- `backend/notification`:飞书 webhook provider、notification 状态机和重试逻辑的已有基座。
|
||||||
|
|
||||||
|
### 与 execute 链路的接缝
|
||||||
|
|
||||||
|
主动调度不复制 `task_item`,也不新建一套排程写入链路。
|
||||||
|
|
||||||
|
最小接入方式:
|
||||||
|
|
||||||
|
1. 主动调度 session 只负责识别当前会话是否处于主动调度占管态,并生成待确认 preview。
|
||||||
|
2. preview / 微调 / 确认继续复用现有会话维度的 `ScheduleState` 快照;前端拖拽仍走现有暂存语义,后端只更新同一份状态。
|
||||||
|
3. 用户确认后,后端按 preview / edited changes 做重校验并正式写入 `schedule_events / schedules`。
|
||||||
|
4. 主动调度释放占管后,后续普通聊天直接回到 newAgent `execute`;`execute` 继续在 `ScheduleState / OriginalScheduleState` 上调用现有 `move / swap / place / unplace` 等工具。
|
||||||
|
|
||||||
|
因此,本轮新增的是 session、路由拦截、preview DTO 适配和 graph 裁决;不是新增排程引擎,也不是把待调整任务复制成新任务再排一次。
|
||||||
|
|
||||||
|
平滑接回聊天的边界:
|
||||||
|
|
||||||
|
1. graph 不直接进入 newAgent `execute`,也不把主动调度包装成 ReAct 工具。
|
||||||
|
2. graph 结果由后端转换成 conversation timeline:追问写 `assistant_text`,预览写 `business_card.card_type=active_schedule_preview`。
|
||||||
|
3. `waiting_user_reply / rerunning` 期间,chat 入口拦截用户消息并同步推进 graph;`ready_preview` 或终态后,session 释放,普通聊天自然回到 newAgent。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 分阶段实施总表
|
||||||
|
|
||||||
|
| 阶段 | 状态 | 目标 | 验收点 | 自动化测试 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 阶段 0 | [x] | 补 `estimated_sections` 写入入口 | 创建任务时能稳定写入 1~4 节,主动调度只消费落库值 | 可以,API + DB + `go test` |
|
||||||
|
| 阶段 1 | [x] | 补主动调度 Eino graph 和 LLM 解释 / 补全兜底 | 产生候选、有限裁决、输出解释、保留 fallback | 可以,后端单测 + API 验证 |
|
||||||
|
| 阶段 2 | [x] | 补 `active_schedule_sessions`、聊天拦截和缓存链路 | `waiting_user_reply / rerunning` 拦截生效,`ready_preview` 释放 | 可以,API + DB + 路由验证 |
|
||||||
|
| 阶段 3 | [ ] | 补 `unfinished_feedback`、`ask_user` 闭环和前端最小适配 | 用户在聊天页补信息后能重跑 graph 并刷新 preview | 后端可自动,前端需浏览器验证 |
|
||||||
|
| 阶段 4 | [ ] | 收口飞书通知与会话链接 | `action_url` 指向 `/assistant/{conversation_id}`,通知 payload 从简 | 可以,webhook POST + DB 验证 |
|
||||||
|
| 阶段 5 | [ ] | 跑完第五阶段剩余验收和失败注入脚本 | 冲突、过期、重复确认、重试、dead/skipped 全覆盖 | 可以,基本全自动 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 阶段细则
|
||||||
|
|
||||||
|
### 阶段 0:补 `estimated_sections` 写入入口
|
||||||
|
|
||||||
|
**当前状态**
|
||||||
|
|
||||||
|
`model.Task.EstimatedSections` 已经有了,主动调度消费侧也已经接上,这一阶段的写入侧已经补完,并完成 API、聊天 quick task 和数据库三方验收,可以收口。
|
||||||
|
|
||||||
|
**已完成内容**
|
||||||
|
|
||||||
|
1. 给 `UserAddTaskRequest` 增加 `estimated_sections`。
|
||||||
|
2. 在 `backend/conv/task.go`、`backend/cmd/start.go` 和 quick task 创建入口里把这个值写入 `model.Task`。
|
||||||
|
3. 做统一归一化,缺失或非法时兜底为 `1`,超过上限收敛到 `4`。
|
||||||
|
4. 让 newAgent / 随口记创建任务时能把 LLM 估计结果带入 DB。
|
||||||
|
5. 让任务查询 DTO 和 quick task 卡片也能回显这个字段,方便验收。
|
||||||
|
|
||||||
|
**验收点**
|
||||||
|
|
||||||
|
1. 任务创建后,查询结果里能看到最终写入的 `estimated_sections`。
|
||||||
|
2. 缺失或越界值会被收敛到 `1~4`。
|
||||||
|
3. 主动调度不再在 graph 内重新猜任务耗时。
|
||||||
|
4. 旧任务和历史数据仍能按默认 `1` 兼容。
|
||||||
|
|
||||||
|
**验证记录**
|
||||||
|
|
||||||
|
1. 已执行 `go test ./...`。
|
||||||
|
2. 已清理本次测试生成的 `.gocache` / `.gopath`。
|
||||||
|
3. 普通 `POST /api/v1/task/create` + `GET /api/v1/task/get` + MySQL 对账已通过,返回和落库均为 `estimated_sections=3`。
|
||||||
|
4. `POST /api/v1/agent/chat` 已验证可创建任务,`tasks` 表中 `id=145` 的记录落库为 `title=交实验报告`、`priority=1`、`estimated_sections=1`、`deadline_at=2026-05-02 20:00:00`。
|
||||||
|
5. 当前仓库没有保留临时 `*_test.go`。
|
||||||
|
|
||||||
|
**自动化测试**
|
||||||
|
|
||||||
|
- 可以自动跑。
|
||||||
|
- 建议路径:任务创建 API + DB 断言 + `go test ./...`。
|
||||||
|
- 若当轮实现了输入输出纯函数,临时单测可写完即删,测试后清理 `*_test.go`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段 1:补主动调度 Eino graph 和 LLM 解释 / 补全兜底
|
||||||
|
|
||||||
|
**当前状态**
|
||||||
|
|
||||||
|
现在 graph / selector 已经接上,核心链路是 `BuildContext -> Observe -> GenerateCandidates -> SelectAndExplain -> CreatePreview`,不再停留在固定 top1 过渡实现。
|
||||||
|
|
||||||
|
**收口状态**
|
||||||
|
|
||||||
|
1. graph 已从固定 pipeline 升级为可复用 runner。
|
||||||
|
2. LLM 只在受限候选里做有限选择,后端 fallback 仍保留。
|
||||||
|
3. 这一阶段已经从“待做”转为“已完成”,后续只保留后续调优项。
|
||||||
|
|
||||||
|
**已完成内容**
|
||||||
|
|
||||||
|
1. 把现有 pipeline 整成可扩展 graph,物理位置固定在 `backend/active_scheduler/graph`,不放进 `newAgent`。
|
||||||
|
2. 把 LLM 放到“只读 / 受限工具视图”里;后端仍是候选合法性、粗排和默认裁决的主责任方,LLM 只做有限选择、解释生成和信息补全兜底。
|
||||||
|
3. 给 LLM 的输入分两层:
|
||||||
|
- 基础信息:`trigger_type / target / time_window / missing_info / warnings / before-after 摘要`
|
||||||
|
- 候选层:`candidate_id / candidate_type / summary / preview_change / dimensions`
|
||||||
|
- 不暴露原始全量事实快照
|
||||||
|
4. 增加选择题协议:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "select_candidate",
|
||||||
|
"selected_candidate_id": "xxx",
|
||||||
|
"reason": "选择原因",
|
||||||
|
"user_message_summary": "给用户看的简短解释",
|
||||||
|
"ask_user_question": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
5. 支持 `select_candidate / ask_user / notify_only / close`。
|
||||||
|
6. 保留后端 fallback:LLM 超时、输出非法、候选不存在时回落到后端粗排结果;事实不足时回落到后端 `ask_user` 问题。
|
||||||
|
7. 模型接入只走一次同步 JSON 调用:启动期复用 `inits.InitEino()` 里的 `aiHub.Pro`,再包装成 `backend/infra/llm.Client`,由 `backend/active_scheduler/selection` 层调用 `GenerateJSON`;默认不走流式、不走 ReAct、不开放工具。
|
||||||
|
8. 候选维度只保留真正有用的最小集:
|
||||||
|
- `capacity_fit`
|
||||||
|
- `risk_level`
|
||||||
|
9. `confidence` 不作为 LLM 可见候选维度;事实可信度只用于内部 trace / `ask_user` 判断,低可信事实不生成正式候选。
|
||||||
|
10. 明确不把这些东西重新做成第一版主维度:
|
||||||
|
- `deadline_fit`
|
||||||
|
- `user_preference_fit`
|
||||||
|
- `disruption`
|
||||||
|
- `reversibility`
|
||||||
|
- `explainability`
|
||||||
|
|
||||||
|
**验收点**
|
||||||
|
|
||||||
|
1. dry-run 和正式 trigger 都能走到 graph。
|
||||||
|
2. graph 能产出多个合法候选,而不是只剩一个 first-fit。
|
||||||
|
3. LLM 不直接写正式日程参数,也不替代后端粗排;它的主要价值是解释、在接近候选间有限裁决、以及事实不足时生成更自然的追问。
|
||||||
|
4. LLM 能看到基础上下文和候选摘要,但不会拿到原始全量快照;候选维度只公开 `capacity_fit / risk_level`。
|
||||||
|
5. `ask_user` 只有在信息不足时才出现。
|
||||||
|
6. `compress_with_next_dynamic_task` 仍然默认关闭,不在这阶段打开。
|
||||||
|
7. LLM selector 能被 fake selector 替换,方便单测覆盖“选第二个候选、输出非法、fallback 命中”这几类情况。
|
||||||
|
|
||||||
|
**自动化测试**
|
||||||
|
|
||||||
|
- 可以自动跑。
|
||||||
|
- 建议路径:候选过滤单测、选择题解析单测、dry-run API 验证、trigger 端到端验证。
|
||||||
|
- 如果 Eino graph 需要调包,实现时要先对照官方文档,再落代码。
|
||||||
|
|
||||||
|
**接入方向**
|
||||||
|
|
||||||
|
这层 graph 放在 `backend/active_scheduler/graph`,由 active scheduler 自己持有业务编排;`newAgent` 不拥有 graph,只在聊天入口被 session 拦截时同步调用 active scheduler service。后续如果拆微服务,把 graph runner 换成 RPC 同步调用即可,不改聊天、worker、API 三个入口的业务口径。
|
||||||
|
|
||||||
|
阶段 1 的落地顺序:
|
||||||
|
|
||||||
|
1. 先落 graph / selection 包,把 `BuildContext -> Observe -> GenerateCandidates -> SelectAndExplain` 这条链串起来。
|
||||||
|
2. 再把 `TriggerWorkflowService` 从固定 pipeline 切到 graph runner,确保 dry-run / trigger 先吃到新裁决结果。
|
||||||
|
3. 然后改 `preview.CreatePreviewRequest` 和 `preview.Service`,让 `SelectedCandidateID / ExplanationText / NotificationSummary / FallbackUsed` 真正进 preview。
|
||||||
|
4. 最后在 `cmd/start.go` 做装配,把 `aiHub.Pro -> llm.Client -> selector -> graph runner` 串进启动期依赖图。
|
||||||
|
5. 完成后先用 API dry-run 和 trigger 验证,再去看 preview detail 是否正确回显 selected candidate。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段 2:补 `active_schedule_sessions`、聊天拦截和缓存链路
|
||||||
|
|
||||||
|
**当前状态**
|
||||||
|
|
||||||
|
`active_schedule_sessions`、session bridge、聊天入口拦截和缓存回填都已经接上,`waiting_user_reply / rerunning` 会先由主动调度接管,`ready_preview` 之后再释放回普通聊天链路。
|
||||||
|
|
||||||
|
**收口状态**
|
||||||
|
|
||||||
|
1. session 表已落地,`session_id / user_id / conversation_id / trigger_id / current_preview_id / status / state_json` 这些核心字段都在代码里。
|
||||||
|
2. 后端 chat 路由已按 session 状态拦截,不会再把占管态消息误进自由聊天。
|
||||||
|
3. Redis + MySQL 的双写回填链路已经打通,session 状态可回源、可回填。
|
||||||
|
4. 这一阶段可以收口,后续只继续补 `unfinished_feedback` 闭环和前端最小适配。
|
||||||
|
|
||||||
|
**已完成内容**
|
||||||
|
|
||||||
|
1. 新增 `active_schedule_sessions`。
|
||||||
|
2. 先把字段压到最小够用集:
|
||||||
|
|
||||||
|
| 字段 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `session_id` | 主动调度会话主键 |
|
||||||
|
| `user_id` | 归属用户 |
|
||||||
|
| `conversation_id` | 绑定到现有聊天会话 |
|
||||||
|
| `trigger_id` | 这次主动调度触发来源 |
|
||||||
|
| `current_preview_id` | 当前正在展示 / 等待处理的 preview |
|
||||||
|
| `status` | 会话状态 |
|
||||||
|
| `state_json` | 轻量业务状态,例如 `pending_question / missing_info / last_candidate_id / last_notification_id / expires_at / failed_reason` |
|
||||||
|
| `created_at` | 创建时间 |
|
||||||
|
| `updated_at` | 更新时间 |
|
||||||
|
|
||||||
|
3. 状态只保留这几种:
|
||||||
|
- `waiting_user_reply`
|
||||||
|
- `rerunning`
|
||||||
|
- `ready_preview`
|
||||||
|
- `applied`
|
||||||
|
- `ignored`
|
||||||
|
- `expired`
|
||||||
|
- `failed`
|
||||||
|
4. 聊天入口在后端直接查 session 状态:
|
||||||
|
- `waiting_user_reply / rerunning`:拦截,先走主动调度补信息链路。
|
||||||
|
- `ready_preview / applied / ignored / expired / failed`:释放给普通聊天。
|
||||||
|
5. session 和 conversation/timeline 分开记账:
|
||||||
|
- timeline 记用户可见对话。
|
||||||
|
- session 记主动调度路由管辖权。
|
||||||
|
6. session 要接缓存链路:
|
||||||
|
- 先查热缓存。
|
||||||
|
- miss 再回源 DB。
|
||||||
|
- 状态变化后同步回填。
|
||||||
|
- 构造用户消息时把 session / preview / pending question 一起缓存好。
|
||||||
|
7. `ask_user` pending 不复用 newAgent `PendingInteraction` 作为状态源,只借鉴交互协议。
|
||||||
|
8. outbox 不作为用户回复重跑 graph 的主驱动;主路径必须同步完成,再给 SSE / timeline 更新。
|
||||||
|
9. graph 结果接回现有聊天协议:
|
||||||
|
- `ask_user`:写 `assistant_text`,session 继续 `waiting_user_reply`。
|
||||||
|
- `select_candidate / ready_preview`:写 `assistant_text` + `business_card.card_type=active_schedule_preview`,session 进入 `ready_preview`。
|
||||||
|
- `close / notify_only / failed`:写 `assistant_text`,session 进入终态并释放普通聊天。
|
||||||
|
|
||||||
|
**验收点**
|
||||||
|
|
||||||
|
1. 同一个 conversation 进入聊天页时,能按 session 状态正确拦截或放行。
|
||||||
|
2. `waiting_user_reply / rerunning` 状态下,用户消息不会直接滑进普通自由聊天链路。
|
||||||
|
3. session 状态变化后,缓存和 DB 一致。
|
||||||
|
4. 关键审计链能串起来:`trigger_id -> preview_id -> conversation_id -> apply_id`。
|
||||||
|
5. 用户刷新会话历史时,主动调度追问和 preview 卡片能从 timeline 重建,不依赖 SSE 临时状态。
|
||||||
|
|
||||||
|
**自动化测试**
|
||||||
|
|
||||||
|
- 可以自动跑。
|
||||||
|
- 建议路径:API + DB 断言、路由拦截测试、缓存命中/回源测试。
|
||||||
|
- 需要你先把对应后端服务按模式起好时,我可以直接接着跑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段 3:补 `unfinished_feedback`、`ask_user` 闭环和前端最小适配
|
||||||
|
|
||||||
|
**当前状态**
|
||||||
|
|
||||||
|
`unfinished_feedback` 目前还偏向“已有目标就能做”,但“定位不稳怎么办、用户回一句怎么办、如何重跑 graph”还没有完全闭环。
|
||||||
|
|
||||||
|
**要做什么**
|
||||||
|
|
||||||
|
1. 定位逻辑按这个顺序走:
|
||||||
|
- LLM 上下文推断
|
||||||
|
- 当前时间
|
||||||
|
- 已排日程窗口
|
||||||
|
2. 定位成功后直接出补做 preview,不移动原任务。
|
||||||
|
3. 定位失败时,不硬猜,直接 `ask_user`。
|
||||||
|
4. `ask_user` 的问题由 `missing_info` 驱动,缺什么问什么。
|
||||||
|
5. 用户在聊天页补全的是当前主动调度缺失事实;偏好类表达不属于 `ask_user` 的缺失项,解锁后直接回到现有 newAgent memory / execute 链路,不在主动调度里新建记忆写入链路。
|
||||||
|
6. 前端只做最小分支:
|
||||||
|
- 复用 `AssistantPanel.vue`
|
||||||
|
- 复用现有 `ScheduleResultCard`
|
||||||
|
- 复用现有 `ScheduleFineTuneModal`
|
||||||
|
- 只是把 timeline 新类型和主动调度 confirm API 接起来
|
||||||
|
7. 后端负责把主动调度 preview DTO 转成前端容易复用的结构,前端不背脏活。
|
||||||
|
|
||||||
|
**验收点**
|
||||||
|
|
||||||
|
1. 用户补完当前主动调度缺失事实后,能刷新 preview 并解除锁定;解锁后再说“我周末不想学习”这类偏好话术时,直接走现有 newAgent memory / execute 链路。
|
||||||
|
2. `ask_user` 流程没走完时,不能直接进入普通聊天链路。
|
||||||
|
3. 补做块只新建,不移动原任务。
|
||||||
|
4. 主动调度卡片能在聊天页显示,微调后走主动调度 confirm API。
|
||||||
|
|
||||||
|
**自动化测试**
|
||||||
|
|
||||||
|
- 后端部分可以自动跑。
|
||||||
|
- 前端卡片展示和按钮分支,建议用浏览器实际打开一次做可视确认。
|
||||||
|
- 如果只是检查 DOM / 路由 / 请求是否发对,能自动;如果要看卡片样式是否真的对齐,还是需要浏览器看一眼。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段 4:收口飞书通知与会话链接
|
||||||
|
|
||||||
|
**当前状态**
|
||||||
|
|
||||||
|
用户级 webhook 配置、通知投递、测试接口已经有基础,但主入口还需要统一收口到聊天会话链接,不能再把旧的 `/schedule-adjust/{preview_id}` 当新目标。
|
||||||
|
|
||||||
|
**要做什么**
|
||||||
|
|
||||||
|
1. 通知前先绑定或预创建 `conversation_id`。
|
||||||
|
2. `action_url` 统一走:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/assistant/{conversation_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 本地测试和示例配置继续用 `localhost`,上线后再换正式域名。
|
||||||
|
4. 业务 JSON 保持从简,只让飞书流程去编排消息,不把复杂卡片协议塞进 webhook。
|
||||||
|
5. 维持当前通知状态机:
|
||||||
|
- `sent`
|
||||||
|
- `failed`
|
||||||
|
- `dead`
|
||||||
|
- `skipped`
|
||||||
|
- retry 相关状态
|
||||||
|
|
||||||
|
**建议 payload 形态**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "smartflow.schedule_adjustment_ready",
|
||||||
|
"version": "1",
|
||||||
|
"notification_id": 123,
|
||||||
|
"user_id": 5,
|
||||||
|
"preview_id": "asp_xxx",
|
||||||
|
"conversation_id": "conv_xxx",
|
||||||
|
"trigger_id": "ast_xxx",
|
||||||
|
"trigger_type": "important_urgent_task",
|
||||||
|
"target_type": "task_pool",
|
||||||
|
"target_id": 81,
|
||||||
|
"message": {
|
||||||
|
"title": "SmartFlow 日程调整建议",
|
||||||
|
"summary": "把重要且紧急任务放入滚动 24 小时内的空闲节次。",
|
||||||
|
"action_text": "查看并确认调整",
|
||||||
|
"action_url": "http://localhost:5173/assistant/conv_xxx"
|
||||||
|
},
|
||||||
|
"trace_id": "trace_xxx",
|
||||||
|
"sent_at": "2026-04-30T17:34:52+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**验收点**
|
||||||
|
|
||||||
|
1. 通知里的跳转链接能直接进聊天页。
|
||||||
|
2. 用户级 webhook 的保存、查询、删除、测试都能跑通。
|
||||||
|
3. 未配置、临时失败、不可恢复失败的状态都能在 `notification_records` 里看见。
|
||||||
|
4. 用户已经在聊天页时,不再强依赖飞书通知承接回复。
|
||||||
|
|
||||||
|
**自动化测试**
|
||||||
|
|
||||||
|
- 可以自动跑。
|
||||||
|
- 建议路径:Webhook POST、测试接口、`notification_records` 状态断言、真实 webhook 收到后人工看一次消息。
|
||||||
|
- 如果需要验证“飞书真的收到”,最终还是要看外部页面一次,但 HTTP 层和状态层可以自动。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段 5:跑完第五阶段剩余验收和失败注入脚本
|
||||||
|
|
||||||
|
**当前状态**
|
||||||
|
|
||||||
|
主链路已经有了,但极限边界还需要系统化收口。
|
||||||
|
|
||||||
|
**要做什么**
|
||||||
|
|
||||||
|
1. 跑通 `api / worker / all` 三种启动模式。
|
||||||
|
2. 覆盖以下边界:
|
||||||
|
- 冲突失败
|
||||||
|
- preview 过期
|
||||||
|
- 重复 confirm
|
||||||
|
- trigger 幂等
|
||||||
|
- notification retry
|
||||||
|
- `dead / skipped / failed`
|
||||||
|
- outbox 重复消费
|
||||||
|
3. 把失败注入做成脚本化验收,不靠手工猜。
|
||||||
|
4. 再扫一遍哪些地方还是空壳,哪些地方只是文档先行。
|
||||||
|
|
||||||
|
**验收点**
|
||||||
|
|
||||||
|
1. 所有核心状态机都能串起来排障。
|
||||||
|
2. 同一条 preview / notification / apply 不会被重复落库。
|
||||||
|
3. 过期、冲突、篡改、失败注入都能拒绝。
|
||||||
|
4. 最终能把这一轮主动调度缺口标成完成。
|
||||||
|
|
||||||
|
**自动化测试**
|
||||||
|
|
||||||
|
- 可以自动跑,而且这一阶段基本就是为了自动化收口。
|
||||||
|
- 绝大多数验收都能用 API + DB + 日志完成。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 已拍板、不再反复讨论的口径
|
||||||
|
|
||||||
|
这些口径已经在讨论里定过了,后面实施时按这个来,不再重开。
|
||||||
|
|
||||||
|
1. 主动调度不做独立的 `/schedule-adjust/{preview_id}` 主入口,主链接统一到 `/assistant/{conversation_id}`。
|
||||||
|
2. `active_schedule_sessions` 要单独建,不塞进现有 conversation 表,也不只加在 `active_schedule_previews` 上。
|
||||||
|
3. `waiting_user_reply / rerunning` 的聊天拦截在后端做,前端只做最小分支。
|
||||||
|
4. `ask_user` pending 不复用 newAgent 的 `PendingInteraction` 作为状态源。
|
||||||
|
5. `compress_with_next_dynamic_task` 第一版继续关闭,只保留 schema / 口径,不生成候选。
|
||||||
|
6. 候选维度保持极简,`deadline_fit` 和 `user_preference_fit` 不再作为第一版公开维度。
|
||||||
|
7. 本轮不再预留独立的健康分支,候选公开维度直接按 `capacity_fit / risk_level` 执行;事实可信度只保留为内部 trace / `ask_user` 门控。
|
||||||
|
8. `unfinished_feedback` 先定位,再 `ask_user`,定位成功后直接生成补做 preview,不移动原任务。
|
||||||
|
9. 用户在聊天页说偏好时,不归主动调度接管;解锁后直接走现有 newAgent memory / execute 链路。
|
||||||
|
10. 只有后台离线自动触达才走飞书;用户已经在会话里时,不需要再先走飞书通知。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 自动化测试边界
|
||||||
|
|
||||||
|
### 可以自动跑的
|
||||||
|
|
||||||
|
- `go test ./...`
|
||||||
|
- dry-run / trigger / preview / confirm 的 API 验证
|
||||||
|
- DB 结果核对
|
||||||
|
- 幂等、重复提交、冲突、过期、失败注入
|
||||||
|
- notification 状态流转
|
||||||
|
- webhook test 接口
|
||||||
|
- api-only / worker-only / all 的后端闭环
|
||||||
|
|
||||||
|
### 部分自动、需要浏览器或你配合开服务的
|
||||||
|
|
||||||
|
- `AssistantPanel.vue` 的实际页面交互
|
||||||
|
- 主动调度卡片是否真的长对了
|
||||||
|
- 飞书真实消息是否真的落到外部页面
|
||||||
|
- 需要切换启动模式时的服务重启
|
||||||
|
|
||||||
|
### 我会怎么标记进度
|
||||||
|
|
||||||
|
每个阶段完成后,我会把对应标题从 `[ ]` 改成 `[x]`,并补三件事:
|
||||||
|
|
||||||
|
1. 实际跑过的命令。
|
||||||
|
2. 关键请求 / 响应 ID。
|
||||||
|
3. DB 核对结果和剩余风险。
|
||||||
@@ -10,12 +10,12 @@
|
|||||||
课程 / 任务事实变化
|
课程 / 任务事实变化
|
||||||
-> 后台观测滚动 24 小时内的任务与日程风险
|
-> 后台观测滚动 24 小时内的任务与日程风险
|
||||||
-> 生成结构化诊断和候选方案
|
-> 生成结构化诊断和候选方案
|
||||||
-> 让 LLM 在候选方案中做选择与表达
|
-> 让 LLM 做解释、追问和有限选择
|
||||||
-> 写入预览 / 触达用户
|
-> 写入预览 / 触达用户
|
||||||
-> 用户回系统确认后再进入正式应用
|
-> 用户回系统确认后再进入正式应用
|
||||||
```
|
```
|
||||||
|
|
||||||
这里的关键是:**系统主动观测,后端给候选,LLM 做选择题,用户掌握最终确认权**。
|
这里的关键是:**系统主动观测,后端给候选并粗排,LLM 做解释 / 追问 / 有限选择,用户掌握最终确认权**。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
|
|
||||||
1. 后端先做结构化观测。
|
1. 后端先做结构化观测。
|
||||||
2. 后端输出问题、指标、裁决和候选操作。
|
2. 后端输出问题、指标、裁决和候选操作。
|
||||||
3. LLM 不做开放式全窗搜索,而是在候选项里选择。
|
3. LLM 不做开放式全窗搜索,也不承担主排序;它只在后端候选里做有限选择与表达。
|
||||||
4. 候选必须是后端验证过合法性和收益的。
|
4. 候选必须是后端验证过合法性和收益的。
|
||||||
5. 如果没有值得继续处理的问题,后端明确返回 close / ask_user,而不是继续诱导 LLM 硬调。
|
5. 如果没有值得继续处理的问题,后端明确返回 close / ask_user,而不是继续诱导 LLM 硬调。
|
||||||
|
|
||||||
@@ -86,13 +86,13 @@
|
|||||||
|
|
||||||
也就是说,新能力更像一个主动调度观测能力,而不是一个自由排程工具。具体工程工具名后续再确认,本阶段只固定职责边界。
|
也就是说,新能力更像一个主动调度观测能力,而不是一个自由排程工具。具体工程工具名后续再确认,本阶段只固定职责边界。
|
||||||
|
|
||||||
### 3.2 让 LLM 做选择题
|
### 3.2 让 LLM 做解释、追问和有限选择
|
||||||
|
|
||||||
LLM 的职责:
|
LLM 的职责:
|
||||||
|
|
||||||
1. 在后端候选方案中选择更符合上下文的一项。
|
1. 把结构化诊断转换成用户能理解的解释。
|
||||||
2. 把结构化诊断转换成用户能理解的解释。
|
2. 在后端候选非常接近时,做有限选择。
|
||||||
3. 在候选不足、信息不足、风险过高时选择 ask_user / close。
|
3. 在信息不足时,把后端 `missing_info` 转成自然追问。
|
||||||
4. 根据主动注入的上下文理解用户偏好,但不调用单独的 `user_preference.get` 工具。
|
4. 根据主动注入的上下文理解用户偏好,但不调用单独的 `user_preference.get` 工具。
|
||||||
|
|
||||||
后端的职责:
|
后端的职责:
|
||||||
@@ -540,7 +540,7 @@ assumed_completed
|
|||||||
4. 动态任务计划时间过去后默认按已完成推进,不主动追问。
|
4. 动态任务计划时间过去后默认按已完成推进,不主动追问。
|
||||||
5. 当前动态任务失败且影响后继时,能按“局部重排 -> 延后结束 -> 压缩融合”顺序生成候选。
|
5. 当前动态任务失败且影响后继时,能按“局部重排 -> 延后结束 -> 压缩融合”顺序生成候选。
|
||||||
6. 能输出结构化 metrics / issues / decision / candidates。
|
6. 能输出结构化 metrics / issues / decision / candidates。
|
||||||
7. 候选项必须是选择题,不让 LLM 自由生成正式写库参数。
|
7. 候选项必须来自后端,不让 LLM 自由生成正式写库参数;LLM 只做解释、追问和有限选择。
|
||||||
8. 不直接写正式 schedule,只写预览或触达用户。
|
8. 不直接写正式 schedule,只写预览或触达用户。
|
||||||
9. 能发布 `notification.feishu.requested` 提醒用户回系统确认。
|
9. 能发布 `notification.feishu.requested` 提醒用户回系统确认。
|
||||||
10. 用户确认后才允许进入正式应用链路。
|
10. 用户确认后才允许进入正式应用链路。
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
当前核心共识:
|
当前核心共识:
|
||||||
|
|
||||||
1. 主动调度主链路走固定 graph / service pipeline,不进入 ReAct 工具循环。
|
1. 主动调度主链路走固定 graph / service pipeline,不进入 ReAct 工具循环。
|
||||||
2. 第一版触发源先做 `important_urgent_task` 与 `unfinished_feedback`。
|
2. 第一版触发类型先做 `important_urgent_task` 与 `unfinished_feedback`,对应的业务目标分别是 task_pool 进日程和未完成反馈补做。
|
||||||
3. task 创建 / 更新时按 `urgency_threshold_at` upsert 主动调度 job;task 完成后把 job 标记为 `canceled`。
|
3. task 创建 / 更新时按 `urgency_threshold_at` upsert 主动调度 job;task 完成后把 job 标记为 `canceled`。
|
||||||
4. schedule 动态任务默认 `assumed_completed`,只有用户明确反馈未完成才触发补救。
|
4. schedule 动态任务默认 `assumed_completed`,只有用户明确反馈未完成才触发补救。
|
||||||
5. 调度触发信号需要持久化,用于幂等、审计、排障和串联 trigger -> preview -> notification -> apply。
|
5. 调度触发信号需要持久化,用于幂等、审计、排障和串联 trigger -> preview -> notification -> apply。
|
||||||
@@ -17,24 +17,26 @@
|
|||||||
7. 主动调度预览新增 `active_schedule_previews`,不塞进 `agent_schedule_states`。
|
7. 主动调度预览新增 `active_schedule_previews`,不塞进 `agent_schedule_states`。
|
||||||
8. 预览保存 `base_version + before_summary + preview_changes`,不保存全量 before 快照。
|
8. 预览保存 `base_version + before_summary + preview_changes`,不保存全量 before 快照。
|
||||||
9. 第一版不做 apply 成功后的撤销按钮;apply 失败必须事务不落库并回写失败原因。
|
9. 第一版不做 apply 成功后的撤销按钮;apply 失败必须事务不落库并回写失败原因。
|
||||||
10. 用户确认入口走主动调度详情页和确认 API,不走 Agent resume;详情页采用助手卡片式体验,支持拖动 after 方案后确认。
|
10. 用户确认入口走现有助手会话页和主动调度确认 API,不走 Agent resume;前端复用助手卡片式体验,支持拖动 after 方案后确认。
|
||||||
11. 预览有效期 1 小时。
|
11. 预览有效期 1 小时。
|
||||||
12. 未完成补救第一版只生成新补做块,不直接移动原已排任务。
|
12. 未完成补救第一版只生成新补做块,不直接移动原已排任务。
|
||||||
13. `schedule.apply.requested` 第一版不走 outbox 异步消费,确认 API 内同步完成重校验和正式应用;成功 / 失败直接回写预览状态。
|
13. `schedule.apply.requested` 第一版不走 outbox 异步消费,确认 API 内同步完成重校验和正式应用;成功 / 失败直接回写预览状态。
|
||||||
14. 应用幂等使用独立 `apply_id + idempotency_key`,`preview_id + candidate_id` 只用于定位候选,不作为一次确认尝试的幂等键。
|
14. 应用幂等使用独立 `apply_id + idempotency_key`,`preview_id + candidate_id` 只用于定位候选,不作为一次确认尝试的幂等键。
|
||||||
15. 飞书通知必须包含唯一预览链接 `/schedule-adjust/{preview_id}`;通知文案优先由 LLM 生成摘要,固定模板仅作为失败兜底。
|
15. 飞书通知必须包含唯一会话链接 `/assistant/{conversation_id}`;若会话尚未创建,后端先预创建 `conversation_id` 并绑定主动调度 session 后再发通知。通知文案第一版先复用候选 / preview summary,固定模板作为兜底;LLM summary 作为后续增强分支,不作为当前已验收前提。
|
||||||
16. 飞书通知幂等按 `user_id + trigger_type + time_window` 聚合,不按 `preview_id`;第一版落 `notification_records` 表支撑可观测与失败重试。
|
16. 飞书通知幂等按 `user_id + trigger_type + time_window` 聚合,不按 `preview_id`;第一版落 `notification_records` 表支撑可观测与失败重试。
|
||||||
17. `api / worker / all` 启动边界第一阶段已完成;主动调度 MVP 可直接挂到 worker / 事件链路,不需要等待启动边界拆分。
|
17. `api / worker / all` 启动边界第一阶段已完成;主动调度 MVP 可直接挂到 worker / 事件链路,不需要等待启动边界拆分。
|
||||||
18. 主动调度第一版采用“准独立模块”策略:不放进 `backend/service/active_scheduler`,而是放在 `backend/active_scheduler`;MVP 暂不拆独立 Go module / 独立进程。
|
18. 主动调度第一版采用“准独立模块”策略:不放进 `backend/service/active_scheduler`,而是放在 `backend/active_scheduler`;MVP 暂不拆独立 Go module / 独立进程。
|
||||||
19. 事件契约第一版提前放入 `backend/shared/events`,只承载 event type、event version、payload DTO 和基础校验,不放业务逻辑。
|
19. 事件契约第一版提前放入 `backend/shared/events`,只承载 event type、event version、payload DTO 和基础校验,不放业务逻辑。
|
||||||
20. 主动调度采用 port / adapter 依赖边界:主链路不散落依赖其它领域 DAO;自有表用自有 repo;读取外部事实走 reader port;正式写入走 apply/service port。
|
20. 主动调度采用 port / adapter 依赖边界:主链路不散落依赖其它领域 DAO;自有表用自有 repo;读取外部事实走 reader port;正式写入走 apply/service port。
|
||||||
21. 主动调度验收以“后端链路可观测 + 动作-预期 checklist”为准,覆盖 dry-run、trigger、worker、preview、notification、confirm apply、幂等、过期和失败回写。
|
21. 主动调度验收以“后端链路可观测 + 动作-预期 checklist”为准,覆盖 dry-run、trigger、worker、preview、notification、confirm apply、幂等、过期和失败回写。
|
||||||
22. 本轮给 `tasks` 新增 `estimated_sections`,默认 1,MVP 允许 1~4 节;主动调度只消费该字段,不在调度阶段重新推断任务复杂度。
|
22. 本轮给 `tasks` 新增 `estimated_sections`,模型层、普通任务创建请求和 quick task 创建入口以及主动调度消费侧都已接上,默认 1,MVP 允许 1~4 节;本轮验收已完成收口。
|
||||||
23. 本轮给 `schedule_events` 新增来源与审计字段:`task_source_type / makeup_for_event_id / active_preview_id`。
|
23. 本轮给 `schedule_events` 新增来源与审计字段:`task_source_type / makeup_for_event_id / active_preview_id`。
|
||||||
24. `compress_with_next_dynamic_task` 第一轮实现先关闭,不生成该候选;保留 schema 和文档口径,待新增补做块主链路稳定后再打开。
|
24. `compress_with_next_dynamic_task` 第一轮实现先关闭,不生成该候选;保留 schema 和文档口径,待新增补做块主链路稳定后再打开。
|
||||||
25. 飞书第一版使用 mock / webhook 跑通主动触达闭环,不阻塞在用户 open_id 绑定体系上。
|
25. 飞书第一版使用 mock / webhook 跑通主动触达闭环,不阻塞在用户 open_id 绑定体系上。
|
||||||
26. notification 去重窗口第一版固定为 30 分钟。
|
26. notification 去重窗口第一版固定为 30 分钟。
|
||||||
27. 真实飞书第一版走“用户级 Webhook 触发器”而不是群自定义机器人协议:后端按 `user_id` 查用户配置的 webhook URL,POST 极简业务 JSON;私聊、群聊、分支和后续动作由用户在飞书流程里自行编排。
|
27. 真实飞书第一版走“用户级 Webhook 触发器”而不是群自定义机器人协议:后端按 `user_id` 查用户配置的 webhook URL,POST 极简业务 JSON;私聊、群聊、分支和后续动作由用户在飞书流程里自行编排。
|
||||||
|
28. 主动调度进入聊天页时新增 `active_schedule_sessions` 作为路由桥:conversation 只承载用户可见历史,session 负责 `waiting_user_reply / rerunning` 的管辖权和 `ready_preview` 后的释放。
|
||||||
|
29. 主动调度的三层口径要分开:触发来源分 worker 自动触发、API 验收入口、用户在聊天页内的主动入口和 `ask_user` 回复;业务目标分 `important_urgent_task -> task_pool 进日程` 与 `unfinished_feedback -> 新补做块`;投递方式上,只有后台离线自动触达才走飞书,用户已经在会话内时不再先发飞书通知。
|
||||||
|
|
||||||
### 0.1 多阶段推进计划
|
### 0.1 多阶段推进计划
|
||||||
|
|
||||||
@@ -73,6 +75,18 @@
|
|||||||
3. 根据日志和测试结果补齐 trace 字段与错误码。
|
3. 根据日志和测试结果补齐 trace 字段与错误码。
|
||||||
4. 主链路稳定后再评估是否打开压缩融合候选。
|
4. 主链路稳定后再评估是否打开压缩融合候选。
|
||||||
|
|
||||||
|
第六阶段:主动调度 graph 补齐、会话桥与聊天页合流。(待实施)
|
||||||
|
|
||||||
|
0. `estimated_sections` 写入入口已经补完:普通任务创建请求、转换层和 quick task / 随口记创建任务时,都会把 LLM 估计的 1~4 节写入 `tasks.estimated_sections`;主动调度只消费该字段,不在 graph 内重新猜任务耗时。
|
||||||
|
1. 补主动调度 Eino graph:把现有 `BuildContext -> Observe -> GenerateCandidates -> CreatePreview` 固定 pipeline 整理成 graph 节点,并新增 LLM 解释 / 有限选择、`ask_user`、fallback 分支;当前代码里的 first-fit / `Candidates[0]` 只能作为过渡实现。
|
||||||
|
2. 升级候选生成与裁决:生成 topN 合法候选,输出 `capacity_fit / risk_level` 两个公开维度;后端负责粗排和默认裁决,LLM 只在接近候选间做有限选择,并负责解释与补全兜底。
|
||||||
|
3. 新增 `active_schedule_sessions`,记录 `session_id / user_id / conversation_id / trigger_id / current_preview_id / status / state_json` 等核心字段;`state_json` 里收纳 `pending_question / missing_info / last_candidate_id / last_notification_id / expires_at / failed_reason` 这类轻量状态。
|
||||||
|
4. `active_schedule_sessions` 也要接入缓存链路:chat 路由先查 session 热缓存,再回源 DB,状态变化后同步回填;构造用户消息时把 session 上下文、preview 和卡片 payload 一并缓存,避免反复从 DB 重组。
|
||||||
|
5. notification 发出前由后端预创建或绑定 `conversation_id`,飞书 `action_url` 指向现有 `/assistant/{conversation_id}` 路由,不再新增独立 `/schedule-adjust/{preview_id}` 主入口。
|
||||||
|
6. 后端在 newAgent 入口按 session 状态决定是否拦截普通聊天:`waiting_user_reply / rerunning` 由主动调度 graph 同步推进;`ready_preview / applied / ignored / expired / failed` 释放给正常聊天链路。
|
||||||
|
7. 前端只做最小适配:复用 `AssistantPanel.vue`、`ScheduleResultCard` 和 `ScheduleFineTuneModal`,timeline 新增主动调度卡片类型,按钮动作按类型分支到主动调度 confirm API。
|
||||||
|
8. 用户在聊天页补充偏好或缺失事实时,后端先更新 memory / 本轮事实,再重跑 active scheduler graph,生成新 preview 后通过 SSE / timeline 推送同一张卡片形态。
|
||||||
|
|
||||||
### 0.2 子代理并行推进计划
|
### 0.2 子代理并行推进计划
|
||||||
|
|
||||||
可在实现阶段使用 3 到 5 个子代理并行推进,但必须按文件所有权拆分,避免互相覆盖。
|
可在实现阶段使用 3 到 5 个子代理并行推进,但必须按文件所有权拆分,避免互相覆盖。
|
||||||
@@ -108,8 +122,8 @@
|
|||||||
已完成阶段:
|
已完成阶段:
|
||||||
|
|
||||||
1. 第一阶段:数据结构与事件契约。
|
1. 第一阶段:数据结构与事件契约。
|
||||||
- 已新增 `tasks.estimated_sections`,默认 1。
|
- 已新增 `tasks.estimated_sections`,默认 1;普通任务创建和 quick task 创建入口已透传,主动调度消费侧也已接上。
|
||||||
- 已新增 `schedule_events.task_source_type / makeup_for_event_id / active_preview_id`。
|
- 已新增 `schedule_events.task_source_type / makeup_for_event_id / active_preview_id`。
|
||||||
- 已新增主动调度相关 model / DAO / 事件契约:`backend/model/active_schedule.go`、`backend/dao/active_schedule.go`、`backend/shared/events`。
|
- 已新增主动调度相关 model / DAO / 事件契约:`backend/model/active_schedule.go`、`backend/dao/active_schedule.go`、`backend/shared/events`。
|
||||||
- AutoMigrate 已接入,并对历史 `schedule_events.type=task` 做 `task_source_type=task_item` 回填。
|
- AutoMigrate 已接入,并对历史 `schedule_events.type=task` 做 `task_source_type=task_item` 回填。
|
||||||
2. 第二阶段:主动调度 dry-run 主链路。
|
2. 第二阶段:主动调度 dry-run 主链路。
|
||||||
@@ -172,7 +186,7 @@
|
|||||||
- worker 已生成 preview:`preview_id=asp_e6701977-aeed-4bef-9964-29d26014f73d`,`active_schedule_triggers.status=preview_generated`,`active_schedule_previews.status=ready`。
|
- worker 已生成 preview:`preview_id=asp_e6701977-aeed-4bef-9964-29d26014f73d`,`active_schedule_triggers.status=preview_generated`,`active_schedule_previews.status=ready`。
|
||||||
- outbox 两段均消费成功:`active_schedule.triggered` 对应 outbox id 2986 为 `consumed`;`notification.feishu.requested` 对应 outbox id 2987 为 `consumed`。
|
- outbox 两段均消费成功:`active_schedule.triggered` 对应 outbox id 2986 为 `consumed`;`notification.feishu.requested` 对应 outbox id 2987 为 `consumed`。
|
||||||
- notification 投递成功:`notification_records.id=2`,`status=sent`,`attempt_count=1`,`provider_message_id=feishu_webhook_2_1777546395537770600`。
|
- notification 投递成功:`notification_records.id=2`,`status=sent`,`attempt_count=1`,`provider_message_id=feishu_webhook_2_1777546395537770600`。
|
||||||
- `provider_request_json.event=smartflow.schedule_adjustment_ready`,`message.title=SmartFlow 日程调整建议`,`message.action_url=https://smartflow.example.com/schedule-adjust/asp_e6701977-aeed-4bef-9964-29d26014f73d`。
|
- `provider_request_json.event=smartflow.schedule_adjustment_ready`,`message.title=SmartFlow 日程调整建议`,`message.action_url=http://localhost:5173/assistant/conv_xxx`。
|
||||||
- 飞书 webhook 响应:HTTP 200,响应体 `{"code":0,"data":{},"msg":"success"}`。
|
- 飞书 webhook 响应:HTTP 200,响应体 `{"code":0,"data":{},"msg":"success"}`。
|
||||||
8. 第五阶段补充自动验收结果:
|
8. 第五阶段补充自动验收结果:
|
||||||
- skipped 场景:测试账号 `codex_skip_idem_0430_185759`(user_id=8)未配置 webhook,正式 trigger `ast_da60cd1c-1909-4855-ad5d-53125b19fb76` 生成 preview `asp_9e5c9c46-3460-4065-a2b8-1d531cf0c8aa`;`notification_records.id=3` 进入 `skipped`,`last_error_code=recipient_missing`,两段 outbox 均为 `consumed`。
|
- skipped 场景:测试账号 `codex_skip_idem_0430_185759`(user_id=8)未配置 webhook,正式 trigger `ast_da60cd1c-1909-4855-ad5d-53125b19fb76` 生成 preview `asp_9e5c9c46-3460-4065-a2b8-1d531cf0c8aa`;`notification_records.id=3` 进入 `skipped`,`last_error_code=recipient_missing`,两段 outbox 均为 `consumed`。
|
||||||
@@ -190,7 +204,7 @@
|
|||||||
1. 下一步继续第五阶段剩余验收,不需要重做 dry-run / preview / confirm 主链路,也不需要重做第四阶段 provider / handler 主体代码。
|
1. 下一步继续第五阶段剩余验收,不需要重做 dry-run / preview / confirm 主链路,也不需要重做第四阶段 provider / handler 主体代码。
|
||||||
2. 第五阶段剩余重点:
|
2. 第五阶段剩余重点:
|
||||||
- confirm apply 冲突失败、过期拒绝。
|
- confirm apply 冲突失败、过期拒绝。
|
||||||
- 更完整的边界清理:测试数据隔离策略、失败注入脚本化、前端真实地址替换 `smartflow.example.com`。
|
- 更完整的边界清理:测试数据隔离策略、失败注入脚本化、前端真实地址替换为正式域名配置。
|
||||||
4. 工作区注意:
|
4. 工作区注意:
|
||||||
- 另一个前端对话可能在改前端;后端阶段不要碰 `frontend` 相关改动。
|
- 另一个前端对话可能在改前端;后端阶段不要碰 `frontend` 相关改动。
|
||||||
- 当前允许单个 Go 文件 700 行以内;超过 700 再评估拆分。
|
- 当前允许单个 Go 文件 700 行以内;超过 700 再评估拆分。
|
||||||
@@ -246,8 +260,8 @@
|
|||||||
|
|
||||||
### 4.2 已拍板结论
|
### 4.2 已拍板结论
|
||||||
|
|
||||||
1. 第一版触发源是否只做两个:`important_urgent_task` 和 `unfinished_feedback`?
|
1. 第一版触发类型是否只做两个:`important_urgent_task` 和 `unfinished_feedback`?
|
||||||
- 已确认:第一版先做这两类主触发。`fatigue_feedback` 可作为用户反馈类的后续扩展,不抢第一轮主链路。
|
- 已确认:第一版先做这两类主触发,对应 task_pool 进日程和未完成反馈补做。`fatigue_feedback` 可作为用户反馈类的后续扩展,不抢第一轮主链路。
|
||||||
2. API 测试触发是否允许直接同步返回诊断结果,还是必须也写入 outbox 后异步消费?
|
2. API 测试触发是否允许直接同步返回诊断结果,还是必须也写入 outbox 后异步消费?
|
||||||
- 已确认:两种都保留。`dry-run` 同步返回诊断结果,不写预览、不发通知;`trigger` 走正式异步链路,写预览并发布通知事件。
|
- 已确认:两种都保留。`dry-run` 同步返回诊断结果,不写预览、不发通知;`trigger` 走正式异步链路,写预览并发布通知事件。
|
||||||
3. `mock_now` 是否只允许测试接口传入,后台真实 worker 禁止传入?
|
3. `mock_now` 是否只允许测试接口传入,后台真实 worker 禁止传入?
|
||||||
@@ -968,7 +982,7 @@ context 构造成功后,后续 observe 可依赖以下事实已经可用:
|
|||||||
|
|
||||||
### 6.1 业务实现逻辑简述
|
### 6.1 业务实现逻辑简述
|
||||||
|
|
||||||
主动观测能力参考 `analyze_health`:后端先做结构化观测,再生成候选,让 LLM 做选择题。
|
主动观测能力参考 `analyze_health`:后端先做结构化观测,再生成候选;LLM 主要负责解释、有限裁决和信息不足时的追问,不再承担主裁决责任。
|
||||||
|
|
||||||
第一版候选限制为 1 到 3 个,动作范围包括:
|
第一版候选限制为 1 到 3 个,动作范围包括:
|
||||||
|
|
||||||
@@ -1732,15 +1746,15 @@ POST /active-schedule/previews/{preview_id}/ignore
|
|||||||
飞书和 Web 路由:
|
飞书和 Web 路由:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/schedule-adjust/{preview_id}
|
/assistant/{conversation_id}
|
||||||
```
|
```
|
||||||
|
|
||||||
页面打开流程:
|
页面打开流程:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. Web 路由解析 preview_id。
|
1. Web 路由解析 conversation_id。
|
||||||
2. 前端调用 GET /active-schedule/previews/{preview_id}。
|
2. 前端先加载 conversation 历史,再按当前会话上下文拉取主动调度 preview。
|
||||||
3. 后端校验 preview 属于当前用户。
|
3. 后端通过 `active_schedule_sessions` 校验当前会话是否还在主动调度管辖期。
|
||||||
4. 返回详情 DTO。
|
4. 返回详情 DTO。
|
||||||
5. 前端根据 can_confirm / expired / apply_status 展示确认、忽略或历史状态。
|
5. 前端根据 can_confirm / expired / apply_status 展示确认、忽略或历史状态。
|
||||||
```
|
```
|
||||||
@@ -1879,8 +1893,8 @@ expires_at = generated_at + 1h
|
|||||||
|
|
||||||
隔离原因:
|
隔离原因:
|
||||||
|
|
||||||
1. 主动调度 preview 可能来自后台 worker,没有 `conversation_id`。
|
1. 主动调度 preview 只管预览内容本身,不直接承担 `conversation_id` 这类路由职责;会话由 `active_schedule_sessions` 单独承接。
|
||||||
2. 主动调度 preview 绑定 `trigger_id / preview_id / expires_at / apply_status`。
|
2. 主动调度 preview 绑定 `trigger_id / preview_id / expires_at / apply_status`,语义集中且便于审计。
|
||||||
3. 会话排程预览是 Agent state 的派生视图,不适合承载后台通知和 apply 审计。
|
3. 会话排程预览是 Agent state 的派生视图,不适合承载后台通知和 apply 审计。
|
||||||
|
|
||||||
#### 7.3.11 错误处理与可观测
|
#### 7.3.11 错误处理与可观测
|
||||||
@@ -1909,7 +1923,7 @@ expires_at = generated_at + 1h
|
|||||||
集成测试:
|
集成测试:
|
||||||
|
|
||||||
1. worker 写入 `active_schedule_previews` 后,GET 详情能读取完整 before/after。
|
1. worker 写入 `active_schedule_previews` 后,GET 详情能读取完整 before/after。
|
||||||
2. 飞书链接 `/schedule-adjust/{preview_id}` 能进入详情页并读取同一 preview。
|
2. 飞书链接 `/assistant/{conversation_id}` 能进入会话页并读取同一 preview。
|
||||||
3. confirm 原始候选成功,状态变为 `applied`。
|
3. confirm 原始候选成功,状态变为 `applied`。
|
||||||
4. confirm 拖动后的 `edited_changes` 成功,应用内容以 edited changes 为准。
|
4. confirm 拖动后的 `edited_changes` 成功,应用内容以 edited changes 为准。
|
||||||
5. preview 过期后 confirm 被拒绝。
|
5. preview 过期后 confirm 被拒绝。
|
||||||
@@ -2390,11 +2404,11 @@ trace_id
|
|||||||
### 9.2 已拍板结论
|
### 9.2 已拍板结论
|
||||||
|
|
||||||
1. 第一版飞书通知文案是否只需要固定模板?
|
1. 第一版飞书通知文案是否只需要固定模板?
|
||||||
- 已确认:不只用固定模板。既然主动调度链路已经调用 LLM,通知文案优先由 LLM 生成简短 summary。
|
- 已确认:第一版先不把 LLM summary 当作已实现分支。通知文案优先复用候选 / preview summary,固定模板作为 fallback。
|
||||||
- 已确认:固定模板作为 fallback,只有 LLM 生成失败、超时或返回空内容时使用,避免通知链路因为文案生成失败而整体中断。
|
- 已确认:后续如果接入 LLM summary provider,也必须是可失败的增强分支,不能影响通知链路本身。
|
||||||
2. 通知是否必须包含跳转链接?如果包含,Web 端预览详情 URL 规则是什么?
|
2. 通知是否必须包含跳转链接?如果包含,Web 端预览详情 URL 规则是什么?
|
||||||
- 已确认:必须包含跳转链接。
|
- 已确认:必须包含跳转链接。
|
||||||
- 已确认:URL 规则采用 `/schedule-adjust/{preview_id}`,每个主动调度 preview 对应一个唯一调整链接。
|
- 已确认:URL 规则采用现有助手会话路由 `/assistant/{conversation_id}`,每个主动调度会话在发通知前先绑定或预创建 `conversation_id`。
|
||||||
3. 通知幂等键是否按 `preview_id`,还是按 `user_id + trigger_type + time_window`?
|
3. 通知幂等键是否按 `preview_id`,还是按 `user_id + trigger_type + time_window`?
|
||||||
- 已确认:按 `user_id + trigger_type + time_window` 聚合去重,不按 `preview_id`。
|
- 已确认:按 `user_id + trigger_type + time_window` 聚合去重,不按 `preview_id`。
|
||||||
- 已确认:MVP 语义是同一用户同一触发类型在同一时间窗口内只推一次飞书,避免短时间重复打扰;具体 time_window 长度在表结构与状态机阶段细化。
|
- 已确认:MVP 语义是同一用户同一触发类型在同一时间窗口内只推一次飞书,避免短时间重复打扰;具体 time_window 长度在表结构与状态机阶段细化。
|
||||||
@@ -2465,7 +2479,7 @@ FeishuNotificationRequested
|
|||||||
target_type
|
target_type
|
||||||
target_id
|
target_id
|
||||||
dedupe_key
|
dedupe_key
|
||||||
target_url # /schedule-adjust/{preview_id}
|
target_url # /assistant/{conversation_id}
|
||||||
summary_text # LLM 已生成摘要,可为空
|
summary_text # LLM 已生成摘要,可为空
|
||||||
fallback_text
|
fallback_text
|
||||||
trace_id
|
trace_id
|
||||||
@@ -2482,7 +2496,7 @@ aggregate_id = preview_id
|
|||||||
校验规则:
|
校验规则:
|
||||||
|
|
||||||
1. `user_id / preview_id / target_url / dedupe_key` 必填。
|
1. `user_id / preview_id / target_url / dedupe_key` 必填。
|
||||||
2. `target_url` 必须是站内相对路径,例如 `/schedule-adjust/{preview_id}`,不允许 provider payload 携带任意外部跳转链接。
|
2. `target_url` 必须是站内相对路径,例如 `/assistant/{conversation_id}`,不允许 provider payload 携带任意外部跳转链接。
|
||||||
3. `summary_text` 可为空;为空时 handler 使用 fallback 文案。
|
3. `summary_text` 可为空;为空时 handler 使用 fallback 文案。
|
||||||
4. payload 不直接复用 `active_schedule_previews` DB model。
|
4. payload 不直接复用 `active_schedule_previews` DB model。
|
||||||
|
|
||||||
@@ -2599,7 +2613,7 @@ notification:
|
|||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
1. `baseURL` 用于把 `/schedule-adjust/{preview_id}` 拼成飞书可点击链接。
|
1. `baseURL` 用于把 `/assistant/{conversation_id}` 拼成飞书可点击链接。
|
||||||
2. 本地和测试环境默认 `provider=mock`。
|
2. 本地和测试环境默认 `provider=mock`。
|
||||||
3. `notification.enabled=false` 时不调用 provider,但仍可按需要写 `skipped` record 便于验证链路。
|
3. `notification.enabled=false` 时不调用 provider,但仍可按需要写 `skipped` record 便于验证链路。
|
||||||
4. `dedupeWindow` 默认可先与 `important_urgent_task` 的 30 分钟触发去重窗口保持一致。
|
4. `dedupeWindow` 默认可先与 `important_urgent_task` 的 30 分钟触发去重窗口保持一致。
|
||||||
@@ -2625,8 +2639,8 @@ notification:
|
|||||||
1. summary 为空:使用 fallback。
|
1. summary 为空:使用 fallback。
|
||||||
2. summary 过长:截断或使用 fallback,避免飞书卡片超限。
|
2. summary 过长:截断或使用 fallback,避免飞书卡片超限。
|
||||||
3. summary 包含不允许的链接:去除链接或使用 fallback。
|
3. summary 包含不允许的链接:去除链接或使用 fallback。
|
||||||
4. LLM summary 失败不能阻断通知投递。
|
4. summary 生成或校验失败不能阻断通知投递。
|
||||||
5. `fallback_used=true` 必须记录到 `notification_records`,方便排查 LLM 文案质量。
|
5. `fallback_used=true` 必须记录到 `notification_records`,方便排查通知文案质量。
|
||||||
|
|
||||||
#### 9.3.7 通知处理流程
|
#### 9.3.7 通知处理流程
|
||||||
|
|
||||||
@@ -2858,7 +2872,7 @@ notification_provider_latency_ms
|
|||||||
人工验收:
|
人工验收:
|
||||||
|
|
||||||
1. 使用 mock provider 验证 dry-run 不发通知、正式 trigger 发通知记录。
|
1. 使用 mock provider 验证 dry-run 不发通知、正式 trigger 发通知记录。
|
||||||
2. 使用测试飞书 webhook 收到包含 `/schedule-adjust/{preview_id}` 的消息。
|
2. 使用测试飞书 webhook 收到包含 `/assistant/{conversation_id}` 的消息。
|
||||||
3. 模拟 provider 失败后能看到 failed / retry / sent 状态变化。
|
3. 模拟 provider 失败后能看到 failed / retry / sent 状态变化。
|
||||||
4. 30 分钟窗口内重复触发,不重复收到飞书。
|
4. 30 分钟窗口内重复触发,不重复收到飞书。
|
||||||
|
|
||||||
@@ -3363,7 +3377,7 @@ backend/service/events/notification_feishu_requested.go
|
|||||||
### 12.2 最终实施拍板
|
### 12.2 最终实施拍板
|
||||||
|
|
||||||
1. 主动调度相关表和状态机按 4.3 / 7.3 / 9.3 / 10.3 执行。
|
1. 主动调度相关表和状态机按 4.3 / 7.3 / 9.3 / 10.3 执行。
|
||||||
2. `tasks` 本轮新增 `estimated_sections`,默认 1,MVP 允许 1~4。
|
2. `tasks` 本轮新增 `estimated_sections`,默认 1,MVP 允许 1~4;模型层、普通创建入口和主动调度消费侧都已接上。
|
||||||
3. `schedule_events` 本轮新增 `task_source_type / makeup_for_event_id / active_preview_id`。
|
3. `schedule_events` 本轮新增 `task_source_type / makeup_for_event_id / active_preview_id`。
|
||||||
4. `compress_with_next_dynamic_task` 第一轮关闭,不生成候选。
|
4. `compress_with_next_dynamic_task` 第一轮关闭,不生成候选。
|
||||||
5. 飞书第一轮使用 mock / webhook,不依赖用户 open_id 绑定。
|
5. 飞书第一轮使用 mock / webhook,不依赖用户 open_id 绑定。
|
||||||
@@ -3487,7 +3501,7 @@ backend/service/events/notification_feishu_requested.go
|
|||||||
1. 主动调度预览新增独立持久化结构,建议命名为 `active_schedule_previews`。
|
1. 主动调度预览新增独立持久化结构,建议命名为 `active_schedule_previews`。
|
||||||
2. 不复用 `agent_schedule_states` 作为主动调度预览主存储,原因:
|
2. 不复用 `agent_schedule_states` 作为主动调度预览主存储,原因:
|
||||||
- `agent_schedule_states` 强绑定 `conversation_id`,更适合会话内智能排程快照。
|
- `agent_schedule_states` 强绑定 `conversation_id`,更适合会话内智能排程快照。
|
||||||
- 主动调度来自后台 worker,可能没有会话上下文。
|
- 主动调度来自后台 worker,conversation 入口由 `active_schedule_sessions` 在通知前绑定,不塞进 preview 主表。
|
||||||
- 主动调度预览需要绑定 `trigger_id / candidate_id / expires_at / apply_status / notification_status`,语义与会话快照不同。
|
- 主动调度预览需要绑定 `trigger_id / candidate_id / expires_at / apply_status / notification_status`,语义与会话快照不同。
|
||||||
3. 展示协议可以复用:
|
3. 展示协议可以复用:
|
||||||
- 抽通用 `SchedulePreviewChangeItem` / before-after schema。
|
- 抽通用 `SchedulePreviewChangeItem` / before-after schema。
|
||||||
@@ -3523,30 +3537,26 @@ backend/service/events/notification_feishu_requested.go
|
|||||||
|
|
||||||
### 12.12 用户确认入口与聊天增强预留
|
### 12.12 用户确认入口与聊天增强预留
|
||||||
|
|
||||||
1. MVP 不走现有 Agent resume 协议,新增主动调度详情页与主动调度确认 API。
|
1. MVP 不再把主动调度做成独立详情页主入口,而是直接进入现有助手会话页,复用 `AssistantPanel.vue` 的历史、卡片和确认体验。
|
||||||
2. 飞书通知包含 LLM 生成的简短摘要和详情页链接,默认进入:
|
2. 飞书通知在发送前由后端预创建或绑定 `conversation_id`,最终跳转链接使用现有路由:
|
||||||
```text
|
```text
|
||||||
/schedule-adjust/{preview_id}
|
/assistant/{conversation_id}
|
||||||
```
|
```
|
||||||
3. 详情页体验采用“助手卡片式”设计,但后端不依赖完整 Agent Chat:
|
3. 会话页表现尽量不变,后端在 timeline 中注入主动调度消息和卡片:
|
||||||
- 顶部展示助手解释文案。
|
- 顶部仍然是助手解释文案。
|
||||||
- 中间展示日程前后对比卡片。
|
- 中间仍然复用日程前后对比卡片。
|
||||||
- 展示触发原因、建议理由、风险和不调整后果。
|
- 展示触发原因、建议理由、风险和不调整后果。
|
||||||
- 支持用户拖动调整 after 方案。
|
- 支持用户拖动调整 after 方案。
|
||||||
- 支持确认应用、忽略 / 拒绝。
|
- 支持确认应用、忽略 / 拒绝。
|
||||||
4. 拖动后的确认请求必须携带 `edited_changes`,后端重新校验,不信任前端坐标。
|
4. 拖动后的确认请求仍然必须携带 `edited_changes`,后端重新校验,不信任前端坐标。
|
||||||
5. 确认 API 建议语义:
|
5. 确认 API 仍然走主动调度自己的确认语义:
|
||||||
```text
|
```text
|
||||||
POST /active-schedule/previews/:preview_id/confirm
|
POST /active-schedule/previews/:preview_id/confirm
|
||||||
```
|
```
|
||||||
请求包含 `candidate_id / action / edited_changes / idempotency_key`。
|
请求包含 `candidate_id / action / edited_changes / idempotency_key`。
|
||||||
6. 后续增强可把同一个 `preview_id` 导入聊天页:
|
6. 前端只需要一个很小的分支:当 timeline item 是主动调度业务卡片时,按钮动作走主动调度 confirm / discuss;其它消息仍走正常聊天链路。
|
||||||
```text
|
7. 主动调度和普通聊天共用同一个 `conversation_id` 历史,但路由管辖权仍由 `active_schedule_sessions` 控制,`waiting_user_reply / rerunning` 未释放前不进入普通 newAgent 自由聊天。
|
||||||
/agent/chat?active_preview_id=xxx
|
8. 聊天增强必须复用 `active_schedule_previews / preview_changes / confirm API`,不能另起一套确认和应用协议,也不能为了主动调度再建一套独立页面。
|
||||||
```
|
|
||||||
聊天页加载同一份主动调度预览,由助手吐出解释消息和同一张日程卡片。
|
|
||||||
7. 聊天增强必须复用 `active_schedule_previews / preview_changes / confirm API`,不能另起一套确认和应用协议。
|
|
||||||
8. 若用户从详情页点击“和助手讨论”,再创建或绑定 `conversation_id`;主动调度预览本身的 `conversation_id` 保持可空。
|
|
||||||
|
|
||||||
### 12.13 预览过期策略
|
### 12.13 预览过期策略
|
||||||
|
|
||||||
@@ -3583,9 +3593,9 @@ backend/service/events/notification_feishu_requested.go
|
|||||||
- 固定模板只作为 fallback,用于 LLM 超时、失败、返回空内容或内容校验不过时。
|
- 固定模板只作为 fallback,用于 LLM 超时、失败、返回空内容或内容校验不过时。
|
||||||
2. 飞书通知必须包含跳转链接:
|
2. 飞书通知必须包含跳转链接:
|
||||||
```text
|
```text
|
||||||
/schedule-adjust/{preview_id}
|
/assistant/{conversation_id}
|
||||||
```
|
```
|
||||||
每个 `preview_id` 对应唯一详情 / 调整页面,用户从飞书点击后回系统查看并确认。
|
每个 `conversation_id` 对应一段已预创建的助手会话,用户从飞书点击后直接进入同一会话页查看并确认。
|
||||||
3. 通知幂等键按 `user_id + trigger_type + time_window` 聚合,而不是按 `preview_id`。
|
3. 通知幂等键按 `user_id + trigger_type + time_window` 聚合,而不是按 `preview_id`。
|
||||||
4. MVP 的去重含义是:同一用户、同一触发类型、同一时间窗口内只发一条飞书,避免主动调度在短时间内重复打扰用户。
|
4. MVP 的去重含义是:同一用户、同一触发类型、同一时间窗口内只发一条飞书,避免主动调度在短时间内重复打扰用户。
|
||||||
5. 飞书 provider 第一版可以放在 backend worker 内,但必须同步落 `notification_records` 表。
|
5. 飞书 provider 第一版可以放在 backend worker 内,但必须同步落 `notification_records` 表。
|
||||||
@@ -3935,7 +3945,7 @@ ActiveScheduleTrigger
|
|||||||
|
|
||||||
### 13.10 LLM 在选择题模式里的作用
|
### 13.10 LLM 在选择题模式里的作用
|
||||||
|
|
||||||
后端给候选,并不代表 LLM 没有价值。后端负责合法性和硬约束,LLM 负责软约束仲裁与表达。
|
后端给候选,并不代表 LLM 没有价值,但它的决策权要收窄。后端负责合法性、粗排和默认裁决,LLM 负责解释、有限裁决和补全兜底。
|
||||||
|
|
||||||
后端擅长:
|
后端擅长:
|
||||||
|
|
||||||
@@ -3947,18 +3957,19 @@ ActiveScheduleTrigger
|
|||||||
|
|
||||||
LLM 擅长:
|
LLM 擅长:
|
||||||
|
|
||||||
1. 结合用户刚才语气判断是否疲劳。
|
1. 把结构化风险翻译成用户能理解的解释。
|
||||||
2. 在候选分数接近时,根据 memory 软偏好选更容易被接受的方案。
|
2. 在候选非常接近、后端粗排已经给出多个合法方案时,做有限的软裁决。
|
||||||
3. 把结构化风险翻译成用户能理解的解释。
|
3. ask_user 时问得更自然,不让用户觉得被系统打断。
|
||||||
4. ask_user 时问得更自然,不让用户觉得被系统打断。
|
4. notify_only 时用提醒语气,而不是制造焦虑。
|
||||||
5. notify_only 时用提醒语气,而不是制造焦虑。
|
5. 在后端已经判断“信息不足”时,生成更合适的追问措辞。
|
||||||
|
|
||||||
边界:
|
边界:
|
||||||
|
|
||||||
1. LLM 不判断候选是否合法。
|
1. LLM 不判断候选是否合法。
|
||||||
2. LLM 不自由构造新候选。
|
2. LLM 不自由构造新候选。
|
||||||
3. LLM 只在 `decision.action=select_candidate` 时从候选里选。
|
3. LLM 不负责主排序,后端粗排结果优先。
|
||||||
4. `close / ask_user / notify_only` 时,LLM 只负责表达后端裁决理由。
|
4. LLM 只在 `decision.action=select_candidate` 时从候选里做有限选择。
|
||||||
|
5. `close / ask_user / notify_only` 时,LLM 主要负责表达与追问,不负责改写业务裁决。
|
||||||
|
|
||||||
一句话:后端保证不出错,LLM 负责更像人。
|
一句话:后端保证不出错,LLM 负责更像人。
|
||||||
|
|
||||||
@@ -3980,7 +3991,7 @@ LLM 擅长:
|
|||||||
|
|
||||||
1. 没有 issue -> `close`。
|
1. 没有 issue -> `close`。
|
||||||
2. 有 issue,但缺关键事实 -> `ask_user`。
|
2. 有 issue,但缺关键事实 -> `ask_user`。
|
||||||
3. 有 issue,且有合法 candidates -> `select_candidate`。
|
3. 有 issue,且有合法 candidates -> 先由后端粗排,再由 LLM 在接近候选间做有限选择。
|
||||||
4. 有 issue,但没有合法 candidates:
|
4. 有 issue,但没有合法 candidates:
|
||||||
- 如果能通过一个明确问题继续推进 -> `ask_user`。
|
- 如果能通过一个明确问题继续推进 -> `ask_user`。
|
||||||
- 如果问用户也不能立刻推进,只是需要提醒 -> `notify_only`。
|
- 如果问用户也不能立刻推进,只是需要提醒 -> `notify_only`。
|
||||||
@@ -3989,7 +4000,7 @@ LLM 擅长:
|
|||||||
|
|
||||||
1. `close`:重要且紧急 task 已经在 schedule 里,或任务已完成。
|
1. `close`:重要且紧急 task 已经在 schedule 里,或任务已完成。
|
||||||
2. `ask_user`:用户说“刚才那个没做完”,但系统无法定位是哪条 schedule_event;或容量不足,需要问能否延后结束时间。
|
2. `ask_user`:用户说“刚才那个没做完”,但系统无法定位是哪条 schedule_event;或容量不足,需要问能否延后结束时间。
|
||||||
3. `select_candidate`:找到合法的加入日程 / 未完成补救候选;压缩融合第一轮关闭,后续打开后再纳入该分支。
|
3. `select_candidate`:找到合法的加入日程 / 未完成补救候选;压缩融合第一轮关闭,后续打开后再纳入该分支。若候选之间差异很小,LLM 只负责在解释和偏好上做有限补充,不代替后端决策。
|
||||||
4. `notify_only`:有风险但没有安全可挪的任务,也没有一个明确问题能继续推进。
|
4. `notify_only`:有风险但没有安全可挪的任务,也没有一个明确问题能继续推进。
|
||||||
|
|
||||||
### 13.12 未完成补救的局部重排不是全量粗排
|
### 13.12 未完成补救的局部重排不是全量粗排
|
||||||
@@ -4100,27 +4111,27 @@ apply_error
|
|||||||
|
|
||||||
### 13.16 确认入口为什么先做详情页,而不是直接聊天页
|
### 13.16 确认入口为什么先做详情页,而不是直接聊天页
|
||||||
|
|
||||||
聊天页效果最好,但第一版直接做完整聊天页会引入很多复杂度:
|
聊天页效果最好,但第一版直接把主动调度完全塞进自由聊天也会引入很多复杂度:
|
||||||
|
|
||||||
1. 后台 preview 没有天然 `conversation_id`。
|
1. trigger / preview 发出时,session 可能还没有预创建 conversation,需要先由后端补齐。
|
||||||
2. 用户拖动卡片后,要同步到 Agent state 还是 active preview。
|
2. 用户拖动卡片后,需要明确是改 conversation 历史,还是改 active preview 的当前态。
|
||||||
3. 用户一句“换晚点”是否重新跑 graph。
|
3. 用户一句“换晚点”到底是继续补信息,还是重新跑 graph,需要 session 状态来裁决。
|
||||||
4. 聊天 SSE、卡片状态、确认状态要保持一致。
|
4. 聊天 SSE、卡片状态、确认状态要保持一致,不能让前端自己猜路由归属。
|
||||||
5. notification 和 agent channel 容易混边界。
|
5. notification 和 agent channel 容易混边界,必须由后端先定谁在管这段对话。
|
||||||
|
|
||||||
折中方案:
|
折中方案:
|
||||||
|
|
||||||
1. MVP 做主动调度详情页。
|
1. 后端先创建或绑定 `conversation_id`,再把飞书链接发到现有 `/assistant/{conversation_id}` 路由。
|
||||||
2. UI 设计成助手卡片式:
|
2. `active_schedule_sessions` 专门记录这段会话对主动调度流程意味着什么,不替代 conversation 表。
|
||||||
|
3. UI 仍然采用助手卡片式:
|
||||||
- 顶部助手解释。
|
- 顶部助手解释。
|
||||||
- 中间日程对比卡片。
|
- 中间日程对比卡片。
|
||||||
- 支持拖动 after。
|
- 支持拖动 after。
|
||||||
- 支持确认 / 忽略。
|
- 支持确认 / 忽略。
|
||||||
3. 后端仍走 `active_schedule_previews` 和确认 API,不依赖完整 Agent Chat。
|
4. 前端只做一个很小的 timeline 类型分支:主动调度卡片走主动调度按钮,普通消息仍走原来的聊天动作。
|
||||||
4. 后续可以通过 `/agent/chat?active_preview_id=xxx` 把同一份 preview 导入聊天页。
|
5. 后端继续复用 `active_schedule_previews` 和确认 API,不依赖完整 Agent Chat 去重新设计卡片协议。
|
||||||
5. 聊天增强必须复用同一套 preview / changes / confirm API。
|
|
||||||
|
|
||||||
这样第一版稳定,后续聊天效果也能接上,不会重写链路。
|
这样第一版仍然稳定,后续如果要进一步开放自由聊天,也只是在 session 释放后接回原来的聊天链路,不会重写整套入口。
|
||||||
|
|
||||||
### 13.17 预览 1 小时过期的具体语义
|
### 13.17 预览 1 小时过期的具体语义
|
||||||
|
|
||||||
@@ -4263,16 +4274,16 @@ none -> expired
|
|||||||
|
|
||||||
第一版建议同一个 preview 只允许成功 apply 一次。`failed` 后是否允许换一个候选再次确认,先不作为 MVP 主路径;若要支持,应生成新的 `apply_id`,并明确旧失败记录如何保留,避免审计链路被覆盖。
|
第一版建议同一个 preview 只允许成功 apply 一次。`failed` 后是否允许换一个候选再次确认,先不作为 MVP 主路径;若要支持,应生成新的 `apply_id`,并明确旧失败记录如何保留,避免审计链路被覆盖。
|
||||||
|
|
||||||
### 13.20 飞书通知为什么需要 LLM 摘要、链接和记录表
|
### 13.20 飞书通知为什么需要摘要、链接和记录表
|
||||||
|
|
||||||
飞书第一版只做“提醒用户回系统确认”,不在飞书内应用日程,也不做复杂聊天。但它仍然是用户会感知到的主动打扰,因此要兼顾表达质量、跳转确定性和投递可观测。
|
飞书第一版只做“提醒用户回系统确认”,不在飞书内应用日程,也不做复杂聊天。但它仍然是用户会感知到的主动打扰,因此要兼顾表达质量、跳转确定性和投递可观测。
|
||||||
|
|
||||||
通知文案:
|
通知文案:
|
||||||
|
|
||||||
1. 主动调度链路已经让 LLM 参与候选选择和解释,此时让 LLM 生成一段 summary 成本很低。
|
1. 当前第一版先复用候选 / preview summary,不把 LLM summary 当作通知链路的硬依赖。
|
||||||
2. LLM summary 比固定模板更能解释“为什么现在提醒你”,例如任务即将变紧急、刚才反馈未完成、后续日程被挤压等。
|
2. summary 只负责表达,不负责决定是否通知、通知谁、跳到哪里;这些仍由后端结构化字段决定。
|
||||||
3. summary 只负责表达,不负责决定是否通知、通知谁、跳到哪里;这些仍由后端结构化字段决定。
|
3. 固定模板必须保留为 fallback,避免 summary 为空、过长、包含不允许内容或后续增强分支失败时,整条通知链路直接断掉。
|
||||||
4. 固定模板必须保留为 fallback,避免 LLM 超时、失败、内容为空或内容校验不过时,整条通知链路直接断掉。
|
4. 后续如果补接 LLM summary provider,它只能作为增强,不应改变通知是否能够发出这一层级的可靠性。
|
||||||
|
|
||||||
推荐 fallback 方向:
|
推荐 fallback 方向:
|
||||||
|
|
||||||
@@ -4283,15 +4294,15 @@ none -> expired
|
|||||||
链接规则:
|
链接规则:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/schedule-adjust/{preview_id}
|
/assistant/{conversation_id}
|
||||||
```
|
```
|
||||||
|
|
||||||
原因:
|
原因:
|
||||||
|
|
||||||
1. 每个主动调度 preview 都有唯一 `preview_id`,天然适合作为详情页定位键。
|
1. 每条主动调度通知在发出前都绑定一个 `conversation_id`,用户点进来后直接进入现有助手会话页。
|
||||||
2. 用户从飞书点进来后,只进入系统详情页,不在飞书里直接应用日程,避免外部 IM 承担高风险写操作。
|
2. 用户从飞书点进来后,仍然只在系统内确认,不在飞书里直接应用日程,避免外部 IM 承担高风险写操作。
|
||||||
3. URL 不暴露 `candidate_id / apply_id`,因为用户进入详情页后仍可查看候选、拖动 after 方案并生成新的确认尝试。
|
3. URL 不暴露 `candidate_id / apply_id`,因为用户进入会话页后仍可查看候选、拖动 after 方案并生成新的确认尝试。
|
||||||
4. 如果后续接入聊天增强,也应由详情页或聊天页读取同一个 `preview_id`,不能另起一套确认协议。
|
4. `preview_id / trigger_id` 由 `active_schedule_sessions` 在后端解析,前端 URL 不长期承担业务状态拼装。
|
||||||
|
|
||||||
通知幂等键按:
|
通知幂等键按:
|
||||||
|
|
||||||
@@ -4321,7 +4332,7 @@ user_id
|
|||||||
trigger_id
|
trigger_id
|
||||||
preview_id
|
preview_id
|
||||||
dedupe_key # user_id + trigger_type + time_window
|
dedupe_key # user_id + trigger_type + time_window
|
||||||
target_url # /schedule-adjust/{preview_id}
|
target_url # /assistant/{conversation_id}
|
||||||
summary_text
|
summary_text
|
||||||
fallback_used
|
fallback_used
|
||||||
status # pending / sending / sent / failed / dead
|
status # pending / sending / sent / failed / dead
|
||||||
@@ -4399,6 +4410,7 @@ POST /api/v1/notification/channels/feishu/test
|
|||||||
"notification_id": 123,
|
"notification_id": 123,
|
||||||
"user_id": 5,
|
"user_id": 5,
|
||||||
"preview_id": "asp_xxx",
|
"preview_id": "asp_xxx",
|
||||||
|
"conversation_id": "conv_xxx",
|
||||||
"trigger_id": "ast_xxx",
|
"trigger_id": "ast_xxx",
|
||||||
"trigger_type": "important_urgent_task",
|
"trigger_type": "important_urgent_task",
|
||||||
"target_type": "task_pool",
|
"target_type": "task_pool",
|
||||||
@@ -4407,7 +4419,7 @@ POST /api/v1/notification/channels/feishu/test
|
|||||||
"title": "SmartFlow 日程调整建议",
|
"title": "SmartFlow 日程调整建议",
|
||||||
"summary": "把重要且紧急任务放入滚动 24 小时内的空闲节次。",
|
"summary": "把重要且紧急任务放入滚动 24 小时内的空闲节次。",
|
||||||
"action_text": "查看并确认调整",
|
"action_text": "查看并确认调整",
|
||||||
"action_url": "https://smartflow.example.com/schedule-adjust/asp_xxx"
|
"action_url": "http://localhost:5173/assistant/conv_xxx"
|
||||||
},
|
},
|
||||||
"trace_id": "trace_xxx",
|
"trace_id": "trace_xxx",
|
||||||
"sent_at": "2026-04-30T17:34:52+08:00"
|
"sent_at": "2026-04-30T17:34:52+08:00"
|
||||||
@@ -4557,7 +4569,7 @@ MVP 里这些端口的 adapter 可以在 `backend` 内调用现有 service。若
|
|||||||
|
|
||||||
### 14.1 验证目标
|
### 14.1 验证目标
|
||||||
|
|
||||||
主动调度 MVP 的验收重点在后端闭环,而不是前端页面完成度。前端第一版只需要能打开 `/schedule-adjust/{preview_id}`、展示预览、提交确认即可;核心验证应覆盖:
|
主动调度 MVP 的验收重点在后端闭环,而不是前端页面完成度。前端第一版只需要能打开现有 `/assistant/{conversation_id}` 会话页、展示主动调度卡片、提交确认即可;核心验证应覆盖:
|
||||||
|
|
||||||
1. 触发是否正确:task 到达 `urgency_threshold_at`、用户反馈未完成、API 测试触发都能进入统一链路。
|
1. 触发是否正确:task 到达 `urgency_threshold_at`、用户反馈未完成、API 测试触发都能进入统一链路。
|
||||||
2. 去重是否正确:同一触发不会重复生成预览、重复通知或重复 apply。
|
2. 去重是否正确:同一触发不会重复生成预览、重复通知或重复 apply。
|
||||||
@@ -4648,8 +4660,8 @@ tasks
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| preview 生成成功 | 发布 `notification.feishu.requested` 或等价 outbox 事件 |
|
| preview 生成成功 | 发布 `notification.feishu.requested` 或等价 outbox 事件 |
|
||||||
| notification handler 收到事件 | 先写 `notification_records`,再调用 provider |
|
| notification handler 收到事件 | 先写 `notification_records`,再调用 provider |
|
||||||
| LLM summary 生成成功 | 飞书文案使用 summary,包含 `/schedule-adjust/{preview_id}` |
|
| summary 生成成功 | 飞书文案使用候选 / preview summary,包含 `/assistant/{conversation_id}` |
|
||||||
| LLM summary 失败 / 超时 / 空内容 | 使用固定 fallback 文案,通知链路不中断 |
|
| summary 为空 / 过长 / 校验失败 | 使用固定 fallback 文案,通知链路不中断 |
|
||||||
| 飞书 provider 返回成功 | `notification_records.status=sent`,记录 `sent_at / provider_response_json` |
|
| 飞书 provider 返回成功 | `notification_records.status=sent`,记录 `sent_at / provider_response_json` |
|
||||||
| 飞书 provider 返回临时失败 | `notification_records.status=failed`,递增 `attempt_count`,写 `last_error / next_retry_at` |
|
| 飞书 provider 返回临时失败 | `notification_records.status=failed`,递增 `attempt_count`,写 `last_error / next_retry_at` |
|
||||||
| 重试到达上限或不可恢复错误 | `notification_records.status=dead`,不再自动重试 |
|
| 重试到达上限或不可恢复错误 | `notification_records.status=dead`,不再自动重试 |
|
||||||
@@ -4661,7 +4673,7 @@ tasks
|
|||||||
|
|
||||||
| 动作 | 预期 |
|
| 动作 | 预期 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 用户打开 `/schedule-adjust/{preview_id}` | 能读取 preview 详情;如果已过期,页面显示不可确认 |
|
| 用户打开 `/assistant/{conversation_id}` | 能读取主动调度会话历史和 preview 详情;如果已过期,页面显示不可确认 |
|
||||||
| 用户确认原候选 | confirm API 生成 `apply_id`,写入 `applying`,同步重校验后事务写正式日程 |
|
| 用户确认原候选 | confirm API 生成 `apply_id`,写入 `applying`,同步重校验后事务写正式日程 |
|
||||||
| 用户拖动 after 方案后确认 | 请求携带 `edited_changes`,后端重新校验坐标和目标,不信任前端 |
|
| 用户拖动 after 方案后确认 | 请求携带 `edited_changes`,后端重新校验坐标和目标,不信任前端 |
|
||||||
| task_pool 候选确认成功 | 写入 `schedule_events(type=task, task_source_type=task_pool, rel_id=tasks.id)` 和对应 `schedules` 原子节次 |
|
| task_pool 候选确认成功 | 写入 `schedule_events(type=task, task_source_type=task_pool, rel_id=tasks.id)` 和对应 `schedules` 原子节次 |
|
||||||
@@ -4686,7 +4698,7 @@ tasks
|
|||||||
| 动作 | 预期 |
|
| 动作 | 预期 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 构造 LLM 选择超时 | 使用后端 fallback 决策或标记失败,trigger 状态可排障 |
|
| 构造 LLM 选择超时 | 使用后端 fallback 决策或标记失败,trigger 状态可排障 |
|
||||||
| 构造 LLM summary 超时 | 使用固定通知模板,preview 仍可通知 |
|
| 构造 summary 为空或校验失败 | 使用固定通知模板,preview 仍可通知 |
|
||||||
| 构造 DB 写 preview 失败 | trigger 标记 failed,不发布 notification |
|
| 构造 DB 写 preview 失败 | trigger 标记 failed,不发布 notification |
|
||||||
| 构造 notification provider 失败 | preview 保留,notification record 进入 failed / retry,不影响 preview 查询 |
|
| 构造 notification provider 失败 | preview 保留,notification record 进入 failed / retry,不影响 preview 查询 |
|
||||||
| 构造 apply 写 schedule 中途失败 | 事务回滚,`schedule_events / schedules` 不产生半写状态 |
|
| 构造 apply 写 schedule 中途失败 | 事务回滚,`schedule_events / schedules` 不产生半写状态 |
|
||||||
@@ -4738,7 +4750,7 @@ tasks
|
|||||||
- retry 后成功:同一条 record 变为 `sent`,不新建重复通知。
|
- retry 后成功:同一条 record 变为 `sent`,不新建重复通知。
|
||||||
- 真实飞书 webhook / open_id 受限时,必须记录为“需要用户验收”,不能用 mock 结果冒充真实 provider 验收。
|
- 真实飞书 webhook / open_id 受限时,必须记录为“需要用户验收”,不能用 mock 结果冒充真实 provider 验收。
|
||||||
6. 手工验收:
|
6. 手工验收:
|
||||||
- 使用 `/schedule-adjust/{preview_id}` 打开详情页。
|
- 使用 `/assistant/{conversation_id}` 打开会话页。
|
||||||
- 拖动 after 方案并确认。
|
- 拖动 after 方案并确认。
|
||||||
- 查看飞书测试消息跳转。
|
- 查看飞书测试消息跳转。
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,152 @@ export interface TimelineConfirmPayload {
|
|||||||
summary: string
|
summary: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ActiveSchedulePreviewTrigger {
|
||||||
|
trigger_id: string
|
||||||
|
trigger_type: string
|
||||||
|
source: string
|
||||||
|
target_type: string
|
||||||
|
target_id: number
|
||||||
|
requested_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveSchedulePreviewEntry {
|
||||||
|
entry_id: string
|
||||||
|
source_type: string
|
||||||
|
source_id: number
|
||||||
|
title: string
|
||||||
|
start_at?: string
|
||||||
|
end_at?: string
|
||||||
|
week?: number
|
||||||
|
day_of_week?: number
|
||||||
|
section_from?: number
|
||||||
|
section_to?: number
|
||||||
|
status: string
|
||||||
|
editable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveSchedulePreviewVersion {
|
||||||
|
title: string
|
||||||
|
window_start?: string
|
||||||
|
window_end?: string
|
||||||
|
entries: ActiveSchedulePreviewEntry[]
|
||||||
|
summary_lines: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveSchedulePreviewSlot {
|
||||||
|
week: number
|
||||||
|
day_of_week: number
|
||||||
|
section: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveSchedulePreviewSlotSpan {
|
||||||
|
start: ActiveSchedulePreviewSlot
|
||||||
|
end: ActiveSchedulePreviewSlot
|
||||||
|
duration_sections: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveSchedulePreviewChangeItem {
|
||||||
|
change_id: string
|
||||||
|
change_type: string
|
||||||
|
target_type: string
|
||||||
|
target_id: number
|
||||||
|
from_slot?: ActiveSchedulePreviewSlot
|
||||||
|
to_slot?: ActiveSchedulePreviewSlotSpan
|
||||||
|
duration_sections: number
|
||||||
|
affected_event_ids: number[]
|
||||||
|
edited_allowed: boolean
|
||||||
|
metadata?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveSchedulePreviewCandidateTarget {
|
||||||
|
target_type: string
|
||||||
|
target_id: number
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveSchedulePreviewCandidate {
|
||||||
|
candidate_id: string
|
||||||
|
candidate_type: string
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
target: ActiveSchedulePreviewCandidateTarget
|
||||||
|
changes: ActiveSchedulePreviewChangeItem[]
|
||||||
|
before_summary: string
|
||||||
|
after_summary: string
|
||||||
|
risk: string
|
||||||
|
score: number
|
||||||
|
validation: Record<string, any>
|
||||||
|
source: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveSchedulePreviewDetail {
|
||||||
|
preview_id: string
|
||||||
|
status: string
|
||||||
|
apply_status: string
|
||||||
|
expires_at: string
|
||||||
|
generated_at: string
|
||||||
|
expired: boolean
|
||||||
|
trigger: ActiveSchedulePreviewTrigger
|
||||||
|
explanation: string
|
||||||
|
notification_summary: string
|
||||||
|
selected_candidate: ActiveSchedulePreviewCandidate
|
||||||
|
candidates: ActiveSchedulePreviewCandidate[]
|
||||||
|
decision: Record<string, any>
|
||||||
|
metrics: Record<string, any>
|
||||||
|
issues: Array<Record<string, any>>
|
||||||
|
context_summary: Record<string, any>
|
||||||
|
before: ActiveSchedulePreviewVersion
|
||||||
|
after: ActiveSchedulePreviewVersion
|
||||||
|
changes: ActiveSchedulePreviewChangeItem[]
|
||||||
|
risk: Record<string, any>
|
||||||
|
base_version: string
|
||||||
|
can_confirm: boolean
|
||||||
|
can_ignore: boolean
|
||||||
|
trace_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveScheduleConfirmChange {
|
||||||
|
change_id?: string
|
||||||
|
type: string
|
||||||
|
target_type?: string
|
||||||
|
target_id?: number
|
||||||
|
task_id?: number
|
||||||
|
event_id?: number
|
||||||
|
week?: number
|
||||||
|
day_of_week?: number
|
||||||
|
section_from?: number
|
||||||
|
section_to?: number
|
||||||
|
duration_sections?: number
|
||||||
|
makeup_for_event_id?: number
|
||||||
|
source_event_id?: number
|
||||||
|
slots?: ActiveSchedulePreviewSlot[]
|
||||||
|
edited_allowed?: boolean
|
||||||
|
metadata?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveScheduleConfirmRequest {
|
||||||
|
candidate_id: string
|
||||||
|
action: 'confirm'
|
||||||
|
edited_changes?: ActiveScheduleConfirmChange[]
|
||||||
|
idempotency_key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveScheduleConfirmResult {
|
||||||
|
preview_id: string
|
||||||
|
apply_id: string
|
||||||
|
apply_status: string
|
||||||
|
candidate_id: string
|
||||||
|
request_hash?: string
|
||||||
|
request_body_hash?: string
|
||||||
|
skipped_changes?: Array<{
|
||||||
|
change_id?: string
|
||||||
|
change_type: string
|
||||||
|
reason: string
|
||||||
|
}>
|
||||||
|
error_code?: string
|
||||||
|
error_message?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskQueryCardTaskItem {
|
export interface TaskQueryCardTaskItem {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
@@ -36,12 +182,12 @@ export interface TaskQueryCardTaskItem {
|
|||||||
|
|
||||||
export interface TaskQueryCardFilter {
|
export interface TaskQueryCardFilter {
|
||||||
key:
|
key:
|
||||||
| 'quadrant'
|
| 'quadrant'
|
||||||
| 'keyword'
|
| 'keyword'
|
||||||
| 'deadline_after'
|
| 'deadline_after'
|
||||||
| 'deadline_before'
|
| 'deadline_before'
|
||||||
| 'include_completed'
|
| 'include_completed'
|
||||||
| 'sort'
|
| 'sort'
|
||||||
label: string
|
label: string
|
||||||
value: string | number | boolean
|
value: string | number | boolean
|
||||||
operator?: 'eq' | 'contains' | 'gte' | 'lt'
|
operator?: 'eq' | 'contains' | 'gte' | 'lt'
|
||||||
@@ -68,7 +214,7 @@ export interface TaskRecordCardData {
|
|||||||
created_at?: string
|
created_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BusinessCardType = 'task_query' | 'task_record'
|
export type BusinessCardType = 'task_query' | 'task_record' | 'active_schedule_preview'
|
||||||
export type TaskRecordSource = 'quick_note' | 'create_task'
|
export type TaskRecordSource = 'quick_note' | 'create_task'
|
||||||
|
|
||||||
export interface TimelineThinkingSummaryPayload {
|
export interface TimelineThinkingSummaryPayload {
|
||||||
@@ -86,27 +232,27 @@ export interface TimelineBusinessCardPayload {
|
|||||||
title?: string
|
title?: string
|
||||||
summary?: string
|
summary?: string
|
||||||
source?: TaskRecordSource
|
source?: TaskRecordSource
|
||||||
data: TaskQueryCardData | TaskRecordCardData
|
data: TaskQueryCardData | TaskRecordCardData | ActiveSchedulePreviewDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimelineEvent {
|
export interface TimelineEvent {
|
||||||
id: number
|
id: number
|
||||||
seq: number
|
seq: number
|
||||||
kind:
|
kind:
|
||||||
| 'user_text'
|
| 'user_text'
|
||||||
| 'assistant_text'
|
| 'assistant_text'
|
||||||
| 'tool_call'
|
| 'tool_call'
|
||||||
| 'tool_result'
|
| 'tool_result'
|
||||||
| 'confirm_request'
|
| 'confirm_request'
|
||||||
| 'schedule_completed'
|
| 'schedule_completed'
|
||||||
| 'interrupt'
|
| 'interrupt'
|
||||||
| 'status'
|
| 'status'
|
||||||
| 'business_card'
|
| 'business_card'
|
||||||
| 'thinking_summary'
|
| 'thinking_summary'
|
||||||
role?: 'user' | 'assistant'
|
role?: 'user' | 'assistant'
|
||||||
content?: string
|
content?: string
|
||||||
payload?: {
|
payload?: {
|
||||||
/** @deprecated 仅供 Debug 页 mock 路径与 legacy 后端兼容;生产页已切换至 thinking_summary 协议。 */
|
/** @deprecated 仅供 Debug/mock 路径兼容;正式后端已经切到 thinking_summary 协议。 */
|
||||||
reasoning_content?: string
|
reasoning_content?: string
|
||||||
stage?: string
|
stage?: string
|
||||||
block_id?: string
|
block_id?: string
|
||||||
@@ -171,18 +317,52 @@ export async function saveScheduleState(conversationId: string, items: PlacedIte
|
|||||||
export async function applyBatchIntoSchedule(
|
export async function applyBatchIntoSchedule(
|
||||||
taskClassId: number,
|
taskClassId: number,
|
||||||
items: PlacedItem[],
|
items: PlacedItem[],
|
||||||
idempotencyKey: string
|
idempotencyKey: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await http.put<ApiResponse<void>>('/task-class/apply-batch-into-schedule', {
|
await http.put<ApiResponse<void>>(
|
||||||
task_class_id: taskClassId,
|
'/task-class/apply-batch-into-schedule',
|
||||||
items,
|
{
|
||||||
}, {
|
task_class_id: taskClassId,
|
||||||
headers: {
|
items,
|
||||||
'X-Idempotency-Key': idempotencyKey
|
},
|
||||||
}
|
{
|
||||||
})
|
headers: {
|
||||||
|
'X-Idempotency-Key': idempotencyKey,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(extractErrorMessage(error, '保存方案失败'))
|
throw new Error(extractErrorMessage(error, '保存方案失败'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取主动调度预览详情。
|
||||||
|
*/
|
||||||
|
export async function getActiveSchedulePreview(previewId: string): Promise<ActiveSchedulePreviewDetail> {
|
||||||
|
try {
|
||||||
|
const response = await http.get<ApiResponse<ActiveSchedulePreviewDetail>>(`/active-schedule/preview/${previewId}`)
|
||||||
|
return response.data.data
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(extractErrorMessage(error, '获取主动调度预览失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认并应用主动调度预览。
|
||||||
|
*/
|
||||||
|
export async function confirmActiveSchedulePreview(
|
||||||
|
previewId: string,
|
||||||
|
payload: ActiveScheduleConfirmRequest,
|
||||||
|
): Promise<ActiveScheduleConfirmResult> {
|
||||||
|
try {
|
||||||
|
const response = await http.post<ApiResponse<ActiveScheduleConfirmResult>>(
|
||||||
|
`/active-schedule/preview/${previewId}/confirm`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return response.data.data
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(extractErrorMessage(error, '确认主动调度预览失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,11 +2,19 @@
|
|||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import type { HybridScheduleEntry, PlacedItem, SchedulePreviewData } from '@/types/dashboard'
|
import type { HybridScheduleEntry, PlacedItem, SchedulePreviewData } from '@/types/dashboard'
|
||||||
import { saveScheduleState, applyBatchIntoSchedule } from '@/api/schedule_agent'
|
import {
|
||||||
|
saveScheduleState,
|
||||||
|
applyBatchIntoSchedule,
|
||||||
|
confirmActiveSchedulePreview,
|
||||||
|
type ActiveScheduleConfirmChange,
|
||||||
|
type ActiveSchedulePreviewDetail,
|
||||||
|
} from '@/api/schedule_agent'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
previewData: SchedulePreviewData | null
|
previewData: SchedulePreviewData | null
|
||||||
visible: boolean
|
visible: boolean
|
||||||
|
previewKind?: 'schedule' | 'active_schedule'
|
||||||
|
activePreviewDetail?: ActiveSchedulePreviewDetail | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -91,6 +99,56 @@ function buildPlacedItems(): PlacedItem[] {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveItemSlot(item: HybridScheduleEntry) {
|
||||||
|
return {
|
||||||
|
week: item.week,
|
||||||
|
day_of_week: item.day_of_week,
|
||||||
|
section_from: item.section_from,
|
||||||
|
section_to: item.section_to,
|
||||||
|
duration_sections: item.section_to - item.section_from + 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveActiveChangeItem(change: ActiveSchedulePreviewDetail['changes'][number]) {
|
||||||
|
if (change.target_type === 'task_pool' || change.change_type === 'add_task_pool_to_schedule') {
|
||||||
|
return suggestedItems.value.find(item => item.task_item_id === change.target_id)
|
||||||
|
}
|
||||||
|
return suggestedItems.value.find(item => item.event_id === change.target_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildActiveEditedChanges(): ActiveScheduleConfirmChange[] {
|
||||||
|
if (!props.activePreviewDetail) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.activePreviewDetail.changes.map((change) => {
|
||||||
|
const currentItem = resolveActiveChangeItem(change)
|
||||||
|
const slot = currentItem ? resolveItemSlot(currentItem) : undefined
|
||||||
|
const fallbackSlot = change.to_slot
|
||||||
|
const week = slot?.week ?? fallbackSlot?.start.week ?? 1
|
||||||
|
const dayOfWeek = slot?.day_of_week ?? fallbackSlot?.start.day_of_week ?? 1
|
||||||
|
const sectionFrom = slot?.section_from ?? fallbackSlot?.start.section ?? 1
|
||||||
|
const sectionTo = slot?.section_to ?? fallbackSlot?.end.section ?? sectionFrom
|
||||||
|
const durationSections = slot?.duration_sections ?? fallbackSlot?.duration_sections ?? Math.max(1, sectionTo - sectionFrom + 1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
change_id: change.change_id,
|
||||||
|
type: change.change_type,
|
||||||
|
target_type: change.target_type,
|
||||||
|
target_id: change.target_id,
|
||||||
|
task_id: change.target_type === 'task_pool' ? change.target_id : undefined,
|
||||||
|
event_id: change.target_type === 'schedule_event' ? change.target_id : undefined,
|
||||||
|
week,
|
||||||
|
day_of_week: dayOfWeek,
|
||||||
|
section_from: sectionFrom,
|
||||||
|
section_to: sectionTo,
|
||||||
|
duration_sections: durationSections,
|
||||||
|
edited_allowed: change.edited_allowed,
|
||||||
|
metadata: change.metadata,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 暂存至 State (Redis)
|
* 暂存至 State (Redis)
|
||||||
*/
|
*/
|
||||||
@@ -130,29 +188,46 @@ async function handleOfficialSave() {
|
|||||||
|
|
||||||
isSaving.value = true
|
isSaving.value = true
|
||||||
try {
|
try {
|
||||||
// 按 task_class_id 分组
|
if (props.previewKind === 'active_schedule') {
|
||||||
const courseIndex = buildCoursePositionIndex(suggestedItems.value)
|
const activeDetail = props.activePreviewDetail
|
||||||
const groups = new Map<number, PlacedItem[]>()
|
if (!activeDetail) {
|
||||||
suggestedItems.value.forEach(e => {
|
throw new Error('主动调度预览数据不完整')
|
||||||
if (e.type === 'task' && e.status === 'suggested' && e.task_class_id) {
|
|
||||||
if (!groups.has(e.task_class_id)) groups.set(e.task_class_id, [])
|
|
||||||
groups.get(e.task_class_id)!.push({
|
|
||||||
task_item_id: e.task_item_id,
|
|
||||||
week: e.week,
|
|
||||||
day_of_week: e.day_of_week,
|
|
||||||
start_section: e.section_from,
|
|
||||||
end_section: e.section_to,
|
|
||||||
embed_course_event_id: resolveEmbedCourseEventId(e, courseIndex),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const promises = Array.from(groups.entries()).map(([classId, groupItems]) =>
|
const payload = {
|
||||||
applyBatchIntoSchedule(classId, groupItems, `${officialSaveIdempotencyKey.value}-${classId}`)
|
candidate_id: activeDetail.selected_candidate.candidate_id,
|
||||||
)
|
action: 'confirm' as const,
|
||||||
|
edited_changes: buildActiveEditedChanges(),
|
||||||
|
idempotency_key: officialSaveIdempotencyKey.value,
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.all(promises)
|
await confirmActiveSchedulePreview(activeDetail.preview_id, payload)
|
||||||
ElMessage.success('日程已正式保存到数据库')
|
ElMessage.success('主动调度已确认')
|
||||||
|
} else {
|
||||||
|
// 按 task_class_id 分组
|
||||||
|
const courseIndex = buildCoursePositionIndex(suggestedItems.value)
|
||||||
|
const groups = new Map<number, PlacedItem[]>()
|
||||||
|
suggestedItems.value.forEach(e => {
|
||||||
|
if (e.type === 'task' && e.status === 'suggested' && e.task_class_id) {
|
||||||
|
if (!groups.has(e.task_class_id)) groups.set(e.task_class_id, [])
|
||||||
|
groups.get(e.task_class_id)!.push({
|
||||||
|
task_item_id: e.task_item_id,
|
||||||
|
week: e.week,
|
||||||
|
day_of_week: e.day_of_week,
|
||||||
|
start_section: e.section_from,
|
||||||
|
end_section: e.section_to,
|
||||||
|
embed_course_event_id: resolveEmbedCourseEventId(e, courseIndex),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const promises = Array.from(groups.entries()).map(([classId, groupItems]) =>
|
||||||
|
applyBatchIntoSchedule(classId, groupItems, `${officialSaveIdempotencyKey.value}-${classId}`),
|
||||||
|
)
|
||||||
|
|
||||||
|
await Promise.all(promises)
|
||||||
|
ElMessage.success('日程已正式保存到数据库')
|
||||||
|
}
|
||||||
|
|
||||||
// 保存成功后刷新幂等键,虽然通常弹窗会关闭,但这是为了逻辑严密
|
// 保存成功后刷新幂等键,虽然通常弹窗会关闭,但这是为了逻辑严密
|
||||||
officialSaveIdempotencyKey.value = crypto.randomUUID()
|
officialSaveIdempotencyKey.value = crypto.randomUUID()
|
||||||
|
|||||||
@@ -20,10 +20,13 @@ import {
|
|||||||
import {
|
import {
|
||||||
getSchedulePreview,
|
getSchedulePreview,
|
||||||
getConversationTimeline,
|
getConversationTimeline,
|
||||||
|
getActiveSchedulePreview,
|
||||||
type TimelineEvent,
|
type TimelineEvent,
|
||||||
type TimelineToolPayload,
|
type TimelineToolPayload,
|
||||||
type TimelineConfirmPayload,
|
type TimelineConfirmPayload,
|
||||||
type ToolView
|
type ToolView,
|
||||||
|
type ActiveSchedulePreviewDetail,
|
||||||
|
type ActiveSchedulePreviewEntry,
|
||||||
} from '@/api/schedule_agent'
|
} from '@/api/schedule_agent'
|
||||||
import { refreshToken } from '@/api/auth'
|
import { refreshToken } from '@/api/auth'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -34,6 +37,7 @@ import type {
|
|||||||
ConversationContextStats,
|
ConversationContextStats,
|
||||||
ConversationListItem,
|
ConversationListItem,
|
||||||
ConversationMeta,
|
ConversationMeta,
|
||||||
|
HybridScheduleEntry,
|
||||||
ThinkingModeType,
|
ThinkingModeType,
|
||||||
SchedulePreviewData,
|
SchedulePreviewData,
|
||||||
} from '@/types/dashboard'
|
} from '@/types/dashboard'
|
||||||
@@ -158,6 +162,7 @@ const conversationList = ref<ConversationListItem[]>([])
|
|||||||
const conversationMetaMap = reactive<Record<string, ConversationMeta>>({})
|
const conversationMetaMap = reactive<Record<string, ConversationMeta>>({})
|
||||||
const conversationMessagesMap = reactive<Record<string, AssistantMessage[]>>({})
|
const conversationMessagesMap = reactive<Record<string, AssistantMessage[]>>({})
|
||||||
const unavailableHistoryMap = reactive<Record<string, boolean>>({})
|
const unavailableHistoryMap = reactive<Record<string, boolean>>({})
|
||||||
|
const conversationHistoryLoadErrorMap = reactive<Record<string, string>>({})
|
||||||
const thinkingMessageMap = reactive<Record<string, boolean>>({})
|
const thinkingMessageMap = reactive<Record<string, boolean>>({})
|
||||||
const reasoningCollapsedMap = reactive<Record<string, boolean>>({})
|
const reasoningCollapsedMap = reactive<Record<string, boolean>>({})
|
||||||
const reasoningStartedAtMap = reactive<Record<string, number>>({})
|
const reasoningStartedAtMap = reactive<Record<string, number>>({})
|
||||||
@@ -193,6 +198,8 @@ const THINKING_STREAM_FLUSH_THRESHOLD = 100
|
|||||||
const isFineTuneModalVisible = ref(false)
|
const isFineTuneModalVisible = ref(false)
|
||||||
const fineTuneLoading = ref(false)
|
const fineTuneLoading = ref(false)
|
||||||
const activeFineTuneData = ref<SchedulePreviewData | null>(null)
|
const activeFineTuneData = ref<SchedulePreviewData | null>(null)
|
||||||
|
const activeFineTuneKind = ref<'schedule' | 'active_schedule'>('schedule')
|
||||||
|
const activeFineTuneActiveDetail = ref<ActiveSchedulePreviewDetail | null>(null)
|
||||||
|
|
||||||
// 任务状态叠加层,用于实时同步和交互
|
// 任务状态叠加层,用于实时同步和交互
|
||||||
interface TaskStatusState {
|
interface TaskStatusState {
|
||||||
@@ -289,27 +296,14 @@ async function hydrateTaskStatuses(conversationId: string) {
|
|||||||
messages.forEach(msg => {
|
messages.forEach(msg => {
|
||||||
// 同时也扫描消息本身可能附带的 extra(用于 SSE 在线消息)
|
// 同时也扫描消息本身可能附带的 extra(用于 SSE 在线消息)
|
||||||
if (msg.extra?.business_card) {
|
if (msg.extra?.business_card) {
|
||||||
const card = msg.extra.business_card
|
collectTaskIdsFromBusinessCard(msg.extra.business_card).forEach((id) => ids.add(id))
|
||||||
if (card.card_type === 'task_query') {
|
|
||||||
const data = card.data as any
|
|
||||||
data.tasks?.forEach((t: any) => { if (t.id) ids.add(t.id) })
|
|
||||||
} else if (card.card_type === 'task_record') {
|
|
||||||
const data = card.data as any
|
|
||||||
if (data.id) ids.add(data.id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 扫描历史恢复的卡片事件
|
// 扫描历史恢复的卡片事件
|
||||||
const cardList = businessCardEventsMap[msg.id]
|
const cardList = businessCardEventsMap[msg.id]
|
||||||
if (cardList) {
|
if (cardList) {
|
||||||
cardList.forEach(card => {
|
cardList.forEach(card => {
|
||||||
if (card.card_type === 'task_query') {
|
collectTaskIdsFromBusinessCard(card).forEach((id) => ids.add(id))
|
||||||
const data = card.data as any
|
|
||||||
data.tasks?.forEach((t: any) => { if (t.id) ids.add(t.id) })
|
|
||||||
} else if (card.card_type === 'task_record') {
|
|
||||||
const data = card.data as any
|
|
||||||
if (data.id) ids.add(data.id)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -542,16 +536,22 @@ const selectedConversationSubtitle = computed(() => {
|
|||||||
return `消息 ${messageCount} 条 · 最近更新 ${formatConversationTime(lastMessageAt)}`
|
return `消息 ${messageCount} 条 · 最近更新 ${formatConversationTime(lastMessageAt)}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const shouldShowHistoryFallback = computed(() => {
|
const selectedConversationHistoryNotice = computed(() => {
|
||||||
if (!selectedConversationId.value) {
|
const conversationId = selectedConversationId.value
|
||||||
return false
|
if (!conversationId || isDraftConversationId(conversationId)) {
|
||||||
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (unavailableHistoryMap[conversationId] !== true || rawSelectedMessages.value.length > 0) {
|
||||||
unavailableHistoryMap[selectedConversationId.value] === true &&
|
return ''
|
||||||
rawSelectedMessages.value.length === 0 &&
|
}
|
||||||
(selectedConversation.value?.message_count ?? 0) > 0
|
|
||||||
)
|
const errorText = (conversationHistoryLoadErrorMap[conversationId] || '').toLowerCase()
|
||||||
|
if (errorText.includes('conversation not found')) {
|
||||||
|
return '当前会话不存在或不属于当前账号,请切换到自己的会话。'
|
||||||
|
}
|
||||||
|
|
||||||
|
return '当前会话的历史消息暂时不可读,但你仍然可以继续追问;后续刷新后会自动恢复。'
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedConversationContextStats = computed(() => {
|
const selectedConversationContextStats = computed(() => {
|
||||||
@@ -1106,6 +1106,143 @@ function appendBusinessCardEvent(messageId: string, payload: TimelineBusinessCar
|
|||||||
assistantTimelineLastKindMap[messageId] = 'business_card'
|
assistantTimelineLastKindMap[messageId] = 'business_card'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isActiveSchedulePreviewCard(payload: TimelineBusinessCardPayload): payload is TimelineBusinessCardPayload & {
|
||||||
|
card_type: 'active_schedule_preview'
|
||||||
|
data: ActiveSchedulePreviewDetail
|
||||||
|
} {
|
||||||
|
return payload.card_type === 'active_schedule_preview'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActiveSchedulePreviewDetail(value: unknown): value is ActiveSchedulePreviewDetail {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const detail = value as Partial<ActiveSchedulePreviewDetail>
|
||||||
|
return typeof detail.preview_id === 'string' && detail.preview_id.trim().length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function needsActiveSchedulePreviewHydration(detail: ActiveSchedulePreviewDetail | null | undefined) {
|
||||||
|
if (!detail) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return !Array.isArray(detail.changes) || !Array.isArray(detail.before?.entries) || !Array.isArray(detail.after?.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveActiveSchedulePreviewSummary(
|
||||||
|
detail: ActiveSchedulePreviewDetail,
|
||||||
|
payload?: Pick<TimelineBusinessCardPayload, 'title' | 'summary'>,
|
||||||
|
) {
|
||||||
|
const candidates = [
|
||||||
|
payload?.summary,
|
||||||
|
payload?.title,
|
||||||
|
detail.notification_summary,
|
||||||
|
detail.explanation,
|
||||||
|
detail.selected_candidate?.summary,
|
||||||
|
]
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const text = `${candidate || ''}`.trim()
|
||||||
|
if (text) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '已生成主动调度建议'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildActiveScheduleHybridEntry(
|
||||||
|
entry: ActiveSchedulePreviewEntry,
|
||||||
|
status: 'existing' | 'suggested',
|
||||||
|
): HybridScheduleEntry | null {
|
||||||
|
// 1. 当前微调弹窗必须依赖周 / 星期 / 节次坐标渲染棋盘。
|
||||||
|
// 2. 若后端暂未给出完整坐标,则先跳过该条,避免把无效块渲染到错误位置。
|
||||||
|
// 3. 后续若后端补齐更多 entry 语义,再继续扩展这层映射,而不是在 UI 里兜底猜位置。
|
||||||
|
if (!entry.week || !entry.day_of_week || !entry.section_from || !entry.section_to) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCourse = entry.source_type === 'course'
|
||||||
|
return {
|
||||||
|
week: entry.week,
|
||||||
|
day_of_week: entry.day_of_week,
|
||||||
|
section_from: entry.section_from,
|
||||||
|
section_to: entry.section_to,
|
||||||
|
name: entry.title || '未命名事项',
|
||||||
|
type: isCourse ? 'course' : 'task',
|
||||||
|
status,
|
||||||
|
task_item_id: isCourse ? 0 : entry.source_id,
|
||||||
|
task_class_id: 0,
|
||||||
|
event_id: entry.source_id,
|
||||||
|
can_be_embedded: isCourse ? entry.editable : false,
|
||||||
|
block_for_suggested: isCourse,
|
||||||
|
context_tag: entry.source_type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSchedulePreviewFromActiveDetail(
|
||||||
|
conversationId: string,
|
||||||
|
detail: ActiveSchedulePreviewDetail,
|
||||||
|
payload?: Pick<TimelineBusinessCardPayload, 'title' | 'summary'>,
|
||||||
|
): SchedulePreviewData {
|
||||||
|
// 1. 现有微调弹窗依赖 hybrid_entries,因此这里把主动调度 before/after 轻量翻译为旧预览结构。
|
||||||
|
// 2. before.entries 作为“当前已存在棋盘”,after.entries 中 status=added 的条目作为“待确认建议块”。
|
||||||
|
// 3. 这层只做前端展示适配,不擅自改写后端 preview 语义;真正的 confirm 仍走主动调度专用 API。
|
||||||
|
const existingEntries = (detail.before?.entries || [])
|
||||||
|
.map((entry) => buildActiveScheduleHybridEntry(entry, 'existing'))
|
||||||
|
.filter((entry): entry is HybridScheduleEntry => Boolean(entry))
|
||||||
|
const suggestedEntries = (detail.after?.entries || [])
|
||||||
|
.filter((entry) => entry.status === 'added')
|
||||||
|
.map((entry) => buildActiveScheduleHybridEntry(entry, 'suggested'))
|
||||||
|
.filter((entry): entry is HybridScheduleEntry => Boolean(entry))
|
||||||
|
|
||||||
|
return {
|
||||||
|
conversation_id: conversationId,
|
||||||
|
trace_id: detail.trace_id || '',
|
||||||
|
summary: resolveActiveSchedulePreviewSummary(detail, payload),
|
||||||
|
candidate_plans: [],
|
||||||
|
hybrid_entries: [...existingEntries, ...suggestedEntries],
|
||||||
|
task_class_ids: [],
|
||||||
|
generated_at: detail.generated_at || new Date().toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectTaskIdsFromBusinessCard(payload: TimelineBusinessCardPayload): number[] {
|
||||||
|
const ids = new Set<number>()
|
||||||
|
|
||||||
|
if (payload.card_type === 'task_query') {
|
||||||
|
(payload.data as TaskQueryCardData).tasks?.forEach((task) => {
|
||||||
|
if (task.id) {
|
||||||
|
ids.add(task.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return Array.from(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.card_type === 'task_record') {
|
||||||
|
const id = (payload.data as TaskRecordCardData).id
|
||||||
|
if (id) {
|
||||||
|
ids.add(id)
|
||||||
|
}
|
||||||
|
return Array.from(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isActiveSchedulePreviewDetail(payload.data)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = payload.data
|
||||||
|
detail.changes?.forEach((change) => {
|
||||||
|
if ((change.target_type === 'task_pool' || change.change_type === 'add_task_pool_to_schedule') && change.target_id > 0) {
|
||||||
|
ids.add(change.target_id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
detail.after?.entries?.forEach((entry) => {
|
||||||
|
if (entry.source_type === 'task_pool' && entry.source_id > 0) {
|
||||||
|
ids.add(entry.source_id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(ids)
|
||||||
|
}
|
||||||
|
|
||||||
function isToolTraceExpanded(eventId: string) {
|
function isToolTraceExpanded(eventId: string) {
|
||||||
return toolTraceExpandedMap[eventId] === true
|
return toolTraceExpandedMap[eventId] === true
|
||||||
}
|
}
|
||||||
@@ -1566,6 +1703,21 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[]
|
|||||||
|
|
||||||
const businessCards = businessCardEventsMap[source.id] || []
|
const businessCards = businessCardEventsMap[source.id] || []
|
||||||
for (const card of businessCards) {
|
for (const card of businessCards) {
|
||||||
|
if (isActiveSchedulePreviewCard(card)) {
|
||||||
|
const activeSchedulePreview = card.data
|
||||||
|
blocks.push({
|
||||||
|
id: `${source.id}:active-schedule-card:${(card as any)._seq}`,
|
||||||
|
type: 'schedule_card',
|
||||||
|
seq: (card as any)._seq,
|
||||||
|
schedulePreview: buildSchedulePreviewFromActiveDetail(source.id, activeSchedulePreview, card),
|
||||||
|
schedulePreviewKind: 'active_schedule',
|
||||||
|
activeSchedulePreview,
|
||||||
|
sourceId: source.id,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
blocks.push({
|
blocks.push({
|
||||||
id: `${source.id}:card:${(card as any)._seq}`,
|
id: `${source.id}:card:${(card as any)._seq}`,
|
||||||
type: 'business_card',
|
type: 'business_card',
|
||||||
@@ -1582,6 +1734,7 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[]
|
|||||||
type: 'schedule_card',
|
type: 'schedule_card',
|
||||||
seq: scheduleResultSeqMap[source.id] || 1000000,
|
seq: scheduleResultSeqMap[source.id] || 1000000,
|
||||||
schedulePreview: scheduleResultMap[source.id],
|
schedulePreview: scheduleResultMap[source.id],
|
||||||
|
schedulePreviewKind: 'schedule',
|
||||||
sourceId: source.id,
|
sourceId: source.id,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
@@ -2256,11 +2409,14 @@ async function loadConversationMessages(conversationId: string, forceReload = fa
|
|||||||
const events = await getConversationTimeline(conversationId)
|
const events = await getConversationTimeline(conversationId)
|
||||||
conversationMessagesMap[conversationId] = rebuildStateFromTimeline(conversationId, events)
|
conversationMessagesMap[conversationId] = rebuildStateFromTimeline(conversationId, events)
|
||||||
unavailableHistoryMap[conversationId] = false
|
unavailableHistoryMap[conversationId] = false
|
||||||
|
delete conversationHistoryLoadErrorMap[conversationId]
|
||||||
// 时间线恢复后,立即启动任务状态同步(Hydration)
|
// 时间线恢复后,立即启动任务状态同步(Hydration)
|
||||||
void hydrateTaskStatuses(conversationId)
|
void hydrateTaskStatuses(conversationId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load timeline:', error)
|
console.error('Failed to load timeline:', error)
|
||||||
unavailableHistoryMap[conversationId] = true
|
unavailableHistoryMap[conversationId] = true
|
||||||
|
conversationHistoryLoadErrorMap[conversationId] =
|
||||||
|
error instanceof Error ? error.message : '会话历史加载失败'
|
||||||
ensureConversationBucket(conversationId)
|
ensureConversationBucket(conversationId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2417,8 +2573,19 @@ function startNewConversation() {
|
|||||||
suppressEmptyStateTransition.value = false
|
suppressEmptyStateTransition.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openFineTuneModal(data: SchedulePreviewData) {
|
async function openFineTuneModal(
|
||||||
// 1. 如果点击的是占位卡片(尚未加载详情),则触发实时拉取。
|
data: SchedulePreviewData,
|
||||||
|
options: {
|
||||||
|
previewKind?: 'schedule' | 'active_schedule'
|
||||||
|
activePreviewDetail?: ActiveSchedulePreviewDetail | null
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const previewKind = options.previewKind || 'schedule'
|
||||||
|
activeFineTuneKind.value = previewKind
|
||||||
|
activeFineTuneActiveDetail.value = options.activePreviewDetail ?? null
|
||||||
|
|
||||||
|
// 1. 普通排程预览仍沿用原来的实时拉取逻辑。
|
||||||
|
// 2. 主动调度预览优先复用时间线里携带的 detail;若只有 preview_id,则按需补拉。
|
||||||
if ((data as any).is_placeholder) {
|
if ((data as any).is_placeholder) {
|
||||||
if (fineTuneLoading.value) return
|
if (fineTuneLoading.value) return
|
||||||
|
|
||||||
@@ -2441,6 +2608,36 @@ async function openFineTuneModal(data: SchedulePreviewData) {
|
|||||||
} finally {
|
} finally {
|
||||||
fineTuneLoading.value = false
|
fineTuneLoading.value = false
|
||||||
}
|
}
|
||||||
|
} else if (previewKind === 'active_schedule') {
|
||||||
|
let activeDetail = activeFineTuneActiveDetail.value
|
||||||
|
if (needsActiveSchedulePreviewHydration(activeDetail) && activeDetail?.preview_id) {
|
||||||
|
if (fineTuneLoading.value) return
|
||||||
|
|
||||||
|
fineTuneLoading.value = true
|
||||||
|
try {
|
||||||
|
activeDetail = await getActiveSchedulePreview(activeDetail.preview_id)
|
||||||
|
activeFineTuneActiveDetail.value = activeDetail
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Load active schedule preview failed:', error)
|
||||||
|
ElMessage.warning('主动调度预览正在生成中,请稍候再试...')
|
||||||
|
return
|
||||||
|
} finally {
|
||||||
|
fineTuneLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeDetail) {
|
||||||
|
ElMessage.warning('主动调度预览数据不完整')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activeFineTuneData.value = buildSchedulePreviewFromActiveDetail(
|
||||||
|
selectedConversationId.value,
|
||||||
|
activeDetail,
|
||||||
|
{
|
||||||
|
summary: data.summary,
|
||||||
|
},
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
activeFineTuneData.value = data
|
activeFineTuneData.value = data
|
||||||
}
|
}
|
||||||
@@ -2450,6 +2647,8 @@ async function openFineTuneModal(data: SchedulePreviewData) {
|
|||||||
|
|
||||||
function closeFineTuneModal() {
|
function closeFineTuneModal() {
|
||||||
isFineTuneModalVisible.value = false
|
isFineTuneModalVisible.value = false
|
||||||
|
activeFineTuneKind.value = 'schedule'
|
||||||
|
activeFineTuneActiveDetail.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleScheduleSaved() {
|
function handleScheduleSaved() {
|
||||||
@@ -2781,16 +2980,7 @@ function handleStreamExtraEvent(extra: StreamExtraPayload | undefined, assistant
|
|||||||
appendBusinessCardEvent(assistantMessage.id, extra.business_card)
|
appendBusinessCardEvent(assistantMessage.id, extra.business_card)
|
||||||
scheduleScrollMessagesToBottom(true)
|
scheduleScrollMessagesToBottom(true)
|
||||||
|
|
||||||
// SSE 在线接收到新卡片时,也尝试同步一次其状态(主要针对立即生成的任务)
|
const ids = collectTaskIdsFromBusinessCard(extra.business_card)
|
||||||
const card = extra.business_card
|
|
||||||
const ids: number[] = []
|
|
||||||
if (card.card_type === 'task_query') {
|
|
||||||
(card.data as TaskQueryCardData).tasks?.forEach(t => { if (t.id) ids.push(t.id) })
|
|
||||||
} else if (card.card_type === 'task_record') {
|
|
||||||
const id = (card.data as TaskRecordCardData).id
|
|
||||||
if (id) ids.push(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ids.length > 0) {
|
if (ids.length > 0) {
|
||||||
ids.forEach(id => {
|
ids.forEach(id => {
|
||||||
if (!taskStatusMap[id]) {
|
if (!taskStatusMap[id]) {
|
||||||
@@ -2993,6 +3183,15 @@ async function sendMessageInternal(options: SendMessageOptions = {}) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentConversationId = selectedConversationId.value
|
||||||
|
if (currentConversationId && !isDraftConversationId(currentConversationId)) {
|
||||||
|
const historyErrorText = (conversationHistoryLoadErrorMap[currentConversationId] || '').toLowerCase()
|
||||||
|
if (historyErrorText.includes('conversation not found')) {
|
||||||
|
ElMessage.warning('当前会话不存在或不属于当前账号,请切换到自己的会话后再发送。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 有 confirm 覆盖层且不是“覆盖层按钮触发”的发送时,阻止误发送。
|
// 1. 有 confirm 覆盖层且不是“覆盖层按钮触发”的发送时,阻止误发送。
|
||||||
// 2. 覆盖层内确认/拒绝按钮会显式传入 bypass,允许继续发送 confirm_action。
|
// 2. 覆盖层内确认/拒绝按钮会显式传入 bypass,允许继续发送 confirm_action。
|
||||||
if (shouldShowDialogConfirmOverlay.value && !options.bypassConfirmOverlayCheck) {
|
if (shouldShowDialogConfirmOverlay.value && !options.bypassConfirmOverlayCheck) {
|
||||||
@@ -3305,8 +3504,8 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
<transition name="chat-content-fade">
|
<transition name="chat-content-fade">
|
||||||
<div :key="conversationTransitionKey" class="assistant-messages__inner">
|
<div :key="conversationTransitionKey" class="assistant-messages__inner">
|
||||||
<div v-if="shouldShowHistoryFallback" class="assistant-chat__fallback">
|
<div v-if="selectedConversationHistoryNotice" class="assistant-chat__fallback">
|
||||||
当前会话的历史消息暂时不可读,但你仍然可以继续追问;后续刷新后会自动恢复。
|
{{ selectedConversationHistoryNotice }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TransitionGroup
|
<TransitionGroup
|
||||||
@@ -3489,7 +3688,10 @@ onBeforeUnmount(() => {
|
|||||||
<template v-else-if="block.type === 'schedule_card' && block.schedulePreview">
|
<template v-else-if="block.type === 'schedule_card' && block.schedulePreview">
|
||||||
<ScheduleResultCard
|
<ScheduleResultCard
|
||||||
:summary="block.schedulePreview.summary"
|
:summary="block.schedulePreview.summary"
|
||||||
@click="openFineTuneModal(block.schedulePreview)"
|
@click="openFineTuneModal(block.schedulePreview, {
|
||||||
|
previewKind: block.schedulePreviewKind,
|
||||||
|
activePreviewDetail: block.activeSchedulePreview,
|
||||||
|
})"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -3722,6 +3924,8 @@ onBeforeUnmount(() => {
|
|||||||
<ScheduleFineTuneModal
|
<ScheduleFineTuneModal
|
||||||
:visible="isFineTuneModalVisible"
|
:visible="isFineTuneModalVisible"
|
||||||
:preview-data="activeFineTuneData"
|
:preview-data="activeFineTuneData"
|
||||||
|
:preview-kind="activeFineTuneKind"
|
||||||
|
:active-preview-detail="activeFineTuneActiveDetail"
|
||||||
@close="closeFineTuneModal"
|
@close="closeFineTuneModal"
|
||||||
@saved="handleScheduleSaved"
|
@saved="handleScheduleSaved"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { TimelineBusinessCardPayload, ToolView } from '@/api/schedule_agent'
|
import type { ActiveSchedulePreviewDetail, TimelineBusinessCardPayload, ToolView } from '@/api/schedule_agent'
|
||||||
import type {
|
import type {
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
ConversationListItem,
|
ConversationListItem,
|
||||||
@@ -129,6 +129,8 @@ export interface DisplayAssistantBlock {
|
|||||||
event?: ToolTraceEvent
|
event?: ToolTraceEvent
|
||||||
statusEvent?: StatusTraceEvent
|
statusEvent?: StatusTraceEvent
|
||||||
schedulePreview?: SchedulePreviewData
|
schedulePreview?: SchedulePreviewData
|
||||||
|
schedulePreviewKind?: 'schedule' | 'active_schedule'
|
||||||
|
activeSchedulePreview?: ActiveSchedulePreviewDetail
|
||||||
businessCard?: TimelineBusinessCardPayload
|
businessCard?: TimelineBusinessCardPayload
|
||||||
/** 所属的源消息 ID,用于状态查询。 */
|
/** 所属的源消息 ID,用于状态查询。 */
|
||||||
sourceId?: string
|
sourceId?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user