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

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

文档:
5. 更新主动调度缺口分阶段实施计划和实现方案,标记阶段 0-2 收口并同步接力状态
2026-05-01 20:48:32 +08:00

233 lines
7.5 KiB
Go
Raw Blame History

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