Version: 0.9.61.dev.260501

后端:
1. 主动调度 graph + session bridge 收口——把 dry-run / select / preview / confirm / rerun 串成受限 graph,新增 active_schedule_sessions 缓存与聊天拦截,ready_preview 后释放回自由聊天
2. 会话与通知链路对齐——notification 统一绑定 conversation_id,action_url 指向 /assistant/{conversation_id},会话不存在改回 404 语义,避免 wrong param type 误导排障
3. estimated_sections 写入与主动调度消费链路补齐——任务创建、quick task 与随口记入口都透传估计节数,主动调度只消费落库值

前端:
4. AssistantPanel 最小适配主动调度预览与失败态——复用主动调度卡片/微调弹窗,补历史加载失败可见提示与跨账号会话拦截

文档:
5. 更新主动调度缺口分阶段实施计划和实现方案,标记阶段 0-2 收口并同步接力状态
This commit is contained in:
Losita
2026-05-01 20:48:32 +08:00
parent 0a014f7472
commit a3eaa9b2c2
42 changed files with 4377 additions and 357 deletions

View File

@@ -0,0 +1,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"
}
}