Version: 0.7.9.dev.260326

后端:
1.把最后一块拼图:schedule_refine也搬迁到了agent2,此时agent已经完全解耦。但是它没融入新架构,Codex只尝试把它调整了一部分,回退了一些错误的更改,保持着现在的可运行状态。下次继续改。
2.agent目录先保留,直到refine彻底融入新架构。
3.改善Codex主导的新史山结构:node文件夹里面大量文件,转而改成了module.go+module_tool.go的双文件格局,极大提升架构整洁度和代码可读性。
前端:
1.新开了日历界面,正在保持往前推进。做了很多更改,感觉越来越好了。
This commit is contained in:
Losita
2026-03-26 00:38:17 +08:00
parent aa04bfb452
commit a243154e23
32 changed files with 11481 additions and 1239 deletions

View File

@@ -10,29 +10,11 @@ import (
)
const (
// SchedulePlanGraphName 是首次排程 graph 的稳定标识。
SchedulePlanGraphName = "schedule_plan"
// ScheduleRefineGraphName 先保留给 refine 链路使用。
SchedulePlanGraphName = "schedule_plan"
ScheduleRefineGraphName = "schedule_refine"
)
// RunSchedulePlanGraph 执行“智能排程”图编排。
//
// 当前链路:
// START
// -> plan
// -> roughBuild
// -> (len(task_class_ids)>=2 ? dailySplit -> dailyRefine -> merge : weeklyRefine)
// -> finalCheck
// -> returnPreview
// -> END
//
// 说明:
// 1. exit 分支可从 plan/roughBuild 直接提前终止;
// 2. 本文件只负责“连线与分支”,节点内业务都在 node 层实现;
// 3. 这轮已经去掉旧 runner 适配层graph 直接挂 node 方法,减少一跳阅读成本。
func RunSchedulePlanGraph(ctx context.Context, input agentnode.SchedulePlanGraphRunInput) (*agentmodel.SchedulePlanState, error) {
// 1. 启动前硬校验。
if input.Model == nil {
return nil, errors.New("schedule plan graph: model is nil")
}
@@ -43,7 +25,6 @@ func RunSchedulePlanGraph(ctx context.Context, input agentnode.SchedulePlanGraph
return nil, err
}
// 2. 注入运行时配置(可选覆盖)。
if input.DailyRefineConcurrency > 0 {
input.State.DailyRefineConcurrency = input.DailyRefineConcurrency
}
@@ -57,8 +38,6 @@ func RunSchedulePlanGraph(ctx context.Context, input agentnode.SchedulePlanGraph
}
graph := compose.NewGraph[*agentmodel.SchedulePlanState, *agentmodel.SchedulePlanState]()
// 3. 注册节点。
if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodePlan, compose.InvokableLambda(nodes.Plan)); err != nil {
return nil, err
}
@@ -90,12 +69,9 @@ func RunSchedulePlanGraph(ctx context.Context, input agentnode.SchedulePlanGraph
return nil, err
}
// 4. 固定入口START -> plan。
if err = graph.AddEdge(compose.START, agentnode.SchedulePlanGraphNodePlan); err != nil {
return nil, err
}
// 5. plan 分支roughBuild | exit。
if err = graph.AddBranch(agentnode.SchedulePlanGraphNodePlan, compose.NewGraphBranch(
nodes.NextAfterPlan,
map[string]bool{
@@ -105,8 +81,6 @@ func RunSchedulePlanGraph(ctx context.Context, input agentnode.SchedulePlanGraph
)); err != nil {
return nil, err
}
// 6. roughBuild 分支dailySplit | quickRefine | weeklyRefine | exit。
if err = graph.AddBranch(agentnode.SchedulePlanGraphNodeRoughBuild, compose.NewGraphBranch(
nodes.NextAfterRoughBuild,
map[string]bool{
@@ -119,7 +93,6 @@ func RunSchedulePlanGraph(ctx context.Context, input agentnode.SchedulePlanGraph
return nil, err
}
// 7. 固定边quickRefine -> weeklyRefinedailySplit -> dailyRefine -> merge -> weeklyRefine -> finalCheck -> returnPreview -> END。
if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeQuickRefine, agentnode.SchedulePlanGraphNodeWeeklyRefine); err != nil {
return nil, err
}
@@ -145,8 +118,6 @@ func RunSchedulePlanGraph(ctx context.Context, input agentnode.SchedulePlanGraph
return nil, err
}
// 8. 编译并执行。
// 路径最多约 8~9 个节点,保守预留 20 步避免误判。
runnable, err := graph.Compile(ctx,
compose.WithGraphName(SchedulePlanGraphName),
compose.WithMaxRunSteps(20),
@@ -158,12 +129,12 @@ func RunSchedulePlanGraph(ctx context.Context, input agentnode.SchedulePlanGraph
return runnable.Invoke(ctx, input.State)
}
// ScheduleRefineGraph 先保留骨架,避免本轮“只迁 schedule_plan”时误动 refine 主链路。
type ScheduleRefineGraph struct {
Nodes *agentnode.ScheduleRefineNodes
}
// NewScheduleRefineGraph 创建连续微调图骨架。
func NewScheduleRefineGraph(nodes *agentnode.ScheduleRefineNodes) *ScheduleRefineGraph {
return &ScheduleRefineGraph{Nodes: nodes}
func RunScheduleRefineGraph(ctx context.Context, input agentnode.ScheduleRefineGraphRunInput) (*agentnode.ScheduleRefineState, error) {
if input.Model == nil {
return nil, errors.New("schedule refine graph: model is nil")
}
if input.State == nil {
return nil, errors.New("schedule refine graph: state is nil")
}
return agentnode.RunScheduleRefineGraph(ctx, input)
}

View File

@@ -0,0 +1,132 @@
package agentllm
import (
"context"
"time"
"github.com/cloudwego/eino-ext/components/model/ark"
)
const scheduleRefineNodeTimeout = 120 * time.Second
type ScheduleRefineContractOutput struct {
Intent string `json:"intent"`
Strategy string `json:"strategy"`
HardRequirements []string `json:"hard_requirements"`
HardAssertions []ScheduleRefineAssertionLite `json:"hard_assertions"`
KeepRelativeOrder bool `json:"keep_relative_order"`
OrderScope string `json:"order_scope"`
}
type ScheduleRefineAssertionLite struct {
Metric string `json:"metric"`
Operator string `json:"operator"`
Value int `json:"value"`
Min int `json:"min"`
Max int `json:"max"`
Week int `json:"week"`
TargetWeek int `json:"target_week"`
}
type ScheduleRefinePlannerOutput struct {
Summary string `json:"summary"`
Steps []string `json:"steps"`
}
type ScheduleRefineToolCall struct {
Tool string `json:"tool"`
Params map[string]any `json:"params"`
}
type ScheduleRefineReactOutput struct {
Done bool `json:"done"`
Summary string `json:"summary"`
GoalCheck string `json:"goal_check"`
Decision string `json:"decision"`
MissingInfo []string `json:"missing_info,omitempty"`
ToolCalls []ScheduleRefineToolCall `json:"tool_calls"`
}
type ScheduleRefinePostReflectOutput struct {
Reflection string `json:"reflection"`
NextStrategy string `json:"next_strategy"`
ShouldStop bool `json:"should_stop"`
}
type ScheduleRefineReviewOutput struct {
Pass bool `json:"pass"`
Reason string `json:"reason"`
Unmet []string `json:"unmet"`
}
func GenerateScheduleRefineContract(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string) (*ScheduleRefineContractOutput, string, error) {
return callScheduleRefineJSON[ScheduleRefineContractOutput](ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{
Temperature: 0,
MaxTokens: 260,
Thinking: ThinkingModeDisabled,
})
}
func GenerateScheduleRefinePlanner(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, maxTokens int) (*ScheduleRefinePlannerOutput, string, error) {
return callScheduleRefineJSON[ScheduleRefinePlannerOutput](ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{
Temperature: 0,
MaxTokens: maxTokens,
Thinking: ThinkingModeDisabled,
})
}
func GenerateScheduleRefineReact(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, useThinking bool, maxTokens int) (string, error) {
thinking := ThinkingModeDisabled
if useThinking {
thinking = ThinkingModeEnabled
}
return callScheduleRefineText(ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{
Temperature: 0,
MaxTokens: maxTokens,
Thinking: thinking,
})
}
func GenerateScheduleRefinePostReflect(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string) (*ScheduleRefinePostReflectOutput, string, error) {
return callScheduleRefineJSON[ScheduleRefinePostReflectOutput](ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{
Temperature: 0,
MaxTokens: 220,
Thinking: ThinkingModeDisabled,
})
}
func GenerateScheduleRefineReview(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string) (*ScheduleRefineReviewOutput, string, error) {
return callScheduleRefineJSON[ScheduleRefineReviewOutput](ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{
Temperature: 0,
MaxTokens: 240,
Thinking: ThinkingModeDisabled,
})
}
func GenerateScheduleRefineSummary(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string) (string, error) {
return callScheduleRefineText(ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{
Temperature: 0.35,
MaxTokens: 280,
Thinking: ThinkingModeDisabled,
})
}
func GenerateScheduleRefineRepair(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string) (string, error) {
return callScheduleRefineText(ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{
Temperature: 0.15,
MaxTokens: 240,
Thinking: ThinkingModeDisabled,
})
}
func callScheduleRefineText(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, options ArkCallOptions) (string, error) {
nodeCtx, cancel := context.WithTimeout(ctx, scheduleRefineNodeTimeout)
defer cancel()
return CallArkText(nodeCtx, chatModel, systemPrompt, userPrompt, options)
}
func callScheduleRefineJSON[T any](ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, options ArkCallOptions) (*T, string, error) {
nodeCtx, cancel := context.WithTimeout(ctx, scheduleRefineNodeTimeout)
defer cancel()
return CallArkJSON[T](nodeCtx, chatModel, systemPrompt, userPrompt, options)
}

View File

@@ -198,13 +198,3 @@ func schedulePlanNowToMinute() time.Time {
func normalizeAdjustmentScope(raw string) string {
return NormalizeSchedulePlanAdjustmentScope(raw)
}
// ScheduleRefineState 先保留现有骨架,避免本轮“只迁 schedule_plan”时误动 refine。
type ScheduleRefineState struct {
TraceID string
UserID int
ConversationID string
UserInput string
Completed bool
FinalSummary string
}

View File

@@ -0,0 +1,344 @@
package agentmodel
import (
"sort"
"strings"
"time"
agentshared "github.com/LoveLosita/smartflow/backend/agent2/shared"
"github.com/LoveLosita/smartflow/backend/model"
)
const (
datetimeLayout = agentshared.MinuteLayout
ScheduleRefineDefaultPlanMax = 2
ScheduleRefineDefaultExecuteMax = 24
ScheduleRefineDefaultPerTaskBudget = 4
ScheduleRefineDefaultReplanMax = 2
ScheduleRefineDefaultCompositeRetry = 2
ScheduleRefineDefaultRepairReserve = 1
)
const (
defaultPlanMax = ScheduleRefineDefaultPlanMax
defaultExecuteMax = ScheduleRefineDefaultExecuteMax
defaultPerTaskBudget = ScheduleRefineDefaultPerTaskBudget
defaultReplanMax = ScheduleRefineDefaultReplanMax
defaultCompositeRetry = ScheduleRefineDefaultCompositeRetry
defaultRepairReserve = ScheduleRefineDefaultRepairReserve
)
// RefineContract 琛ㄧず鏈疆寰皟鎰忓浘濂戠害銆?
type RefineContract struct {
Intent string `json:"intent"`
Strategy string `json:"strategy"`
HardRequirements []string `json:"hard_requirements"`
HardAssertions []RefineAssertion `json:"hard_assertions,omitempty"`
KeepRelativeOrder bool `json:"keep_relative_order"`
OrderScope string `json:"order_scope"`
}
// RefineAssertion 琛ㄧず鍙敱鍚庣鐩存帴鍒ゅ畾鐨勭粨鏋勫寲纭柇瑷€銆?
//
// 瀛楁璇存槑锛?
// 1. Metric锛氭柇瑷€鎸囨爣鍚嶏紝渚嬪 source_move_ratio_percent锛?
// 2. Operator锛氭瘮杈冩搷浣滅锛屾敮鎸?== / <= / >= / between锛?
// 3. Value/Min/Max锛氶槇鍊硷紱
// 4. Week/TargetWeek锛氬彲閫夊懆娆笂涓嬫枃銆?
type RefineAssertion struct {
Metric string `json:"metric"`
Operator string `json:"operator"`
Value int `json:"value,omitempty"`
Min int `json:"min,omitempty"`
Max int `json:"max,omitempty"`
Week int `json:"week,omitempty"`
TargetWeek int `json:"target_week,omitempty"`
}
// HardCheckReport 琛ㄧず缁堝纭牎楠岀粨鏋溿€?
type HardCheckReport struct {
PhysicsPassed bool `json:"physics_passed"`
PhysicsIssues []string `json:"physics_issues,omitempty"`
IntentPassed bool `json:"intent_passed"`
IntentReason string `json:"intent_reason,omitempty"`
IntentUnmet []string `json:"intent_unmet,omitempty"`
OrderPassed bool `json:"order_passed"`
OrderIssues []string `json:"order_issues,omitempty"`
RepairTried bool `json:"repair_tried"`
}
// ReactRoundObservation 璁板綍姣忚疆 ReAct 鐨勫叧閿瀵熴€?
type ReactRoundObservation struct {
Round int `json:"round"`
GoalCheck string `json:"goal_check,omitempty"`
Decision string `json:"decision,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ToolParams map[string]any `json:"tool_params,omitempty"`
ToolSuccess bool `json:"tool_success"`
ToolErrorCode string `json:"tool_error_code,omitempty"`
ToolResult string `json:"tool_result,omitempty"`
Reflect string `json:"reflect,omitempty"`
}
// PlannerPlan 琛ㄧず Planner 鐢熸垚鐨勯樁娈垫墽琛岃鍒掋€?
type PlannerPlan struct {
Summary string `json:"summary"`
Steps []string `json:"steps,omitempty"`
}
// RefineSlicePlan 琛ㄧず鍒囩墖鑺傜偣杈撳嚭銆?
type RefineSlicePlan struct {
WeekFilter []int `json:"week_filter,omitempty"`
SourceDays []int `json:"source_days,omitempty"`
TargetDays []int `json:"target_days,omitempty"`
ExcludeSections []int `json:"exclude_sections,omitempty"`
Reason string `json:"reason,omitempty"`
}
// RefineObjective 琛ㄧず鈥滃彲鎵ц涓斿彲鏍¢獙鈥濈殑鐩爣绾︽潫銆?
//
// 璁捐璇存槑锛?
// 1. 鐢?contract/slice 浠庤嚜鐒惰瑷€缂栬瘧寰楀埌锛?
// 2. 鎵ц闃舵锛坉one 鏀跺彛锛変笌缁堝闃舵锛坔ard_check锛夊叡鐢ㄥ悓涓€浠界害鏉燂紱
// 3. 閬垮厤鈥滄墽琛岄€昏緫涓庣粓瀹¢€昏緫鍚勮鍚勮瘽鈥濄€?
type RefineObjective struct {
Mode string `json:"mode,omitempty"` // none | move_all | move_ratio
SourceWeeks []int `json:"source_weeks,omitempty"`
TargetWeeks []int `json:"target_weeks,omitempty"`
SourceDays []int `json:"source_days,omitempty"`
TargetDays []int `json:"target_days,omitempty"`
ExcludeSections []int `json:"exclude_sections,omitempty"`
BaselineSourceTaskCount int `json:"baseline_source_task_count,omitempty"`
RequiredMoveMin int `json:"required_move_min,omitempty"`
RequiredMoveMax int `json:"required_move_max,omitempty"`
Reason string `json:"reason,omitempty"`
}
// ScheduleRefineState 鏄繛缁井璋冨浘鐨勭粺涓€鐘舵€併€?
type ScheduleRefineState struct {
// 1) 璇锋眰涓婁笅鏂?
TraceID string
UserID int
ConversationID string
UserMessage string
RequestNow time.Time
RequestNowText string
// 2) 缁ф壙鑷瑙堝揩鐓х殑鏁版嵁
TaskClassIDs []int
Constraints []string
// InitialHybridEntries 淇濆瓨鏈疆寰皟寮€濮嬪墠鐨勫熀绾匡紝鐢ㄤ簬缁堝鍋氣€滃墠鍚庡姣斺€濄€?
// 璇存槑锛?
// 1. 鍙璇箟锛屼笉鍙備笌鎵ц鏈熸敼鍐欙紱
// 2. 缁堝鍙熀浜庡畠鍒ゆ柇鈥滄潵婧愪换鍔℃槸鍚︾湡姝h縼绉诲埌鐩爣鍖哄煙鈥濄€?
InitialHybridEntries []model.HybridScheduleEntry
HybridEntries []model.HybridScheduleEntry
AllocatedItems []model.TaskClassItem
CandidatePlans []model.UserWeekSchedule
// 3) 鏈疆鎵ц鐘舵€?
UserIntent string
Contract RefineContract
PlanMax int
PerTaskBudget int
ExecuteMax int
ReplanMax int
// CompositeRetryMax 琛ㄧず澶嶅悎璺敱澶辫触鍚庣殑鏈€澶ч噸璇曟鏁帮紙涓嶅惈棣栨灏濊瘯锛夈€?
CompositeRetryMax int
PlanUsed int
ReplanUsed int
MaxRounds int
RepairReserve int
RoundUsed int
ActionLogs []string
ConsecutiveFailures int
ThinkingBoostArmed bool
ObservationHistory []ReactRoundObservation
CurrentPlan PlannerPlan
BatchMoveAllowed bool
// DisableCompositeTools=true 琛ㄧず宸茶繘鍏?ReAct 鍏滃簳锛岀姝㈠啀璋冪敤澶嶅悎宸ュ叿銆?
DisableCompositeTools bool
// CompositeRouteTried 鏍囪鏄惁灏濊瘯杩団€滃鍚堟壒澶勭悊璺敱鈥濄€?
CompositeRouteTried bool
// CompositeRouteSucceeded 鏍囪澶嶅悎鎵瑰鐞嗚矾鐢辨槸鍚﹀凡瀹屾垚鈥滃鍚堝垎鏀嚭绔欌€濄€?
//
// 璇存槑锛?
// 1. true 琛ㄧず褰撳墠閾捐矾鍙互璺宠繃 ReAct 鍏滃簳锛岀洿鎺ヨ繘鍏?hard_check锛?
// 2. 瀹冧笉绛変环浜庘€滅粓瀹″凡閫氳繃鈥濓紝缁堝鏄惁閫氳繃浠嶄互鍚庣画 HardCheck 缁撴灉涓哄噯锛?
// 3. 杩欐牱鍖哄垎鏄负浜嗛伩鍏嶁€滃鍚堝伐鍏峰凡鎴愬姛鎵ц锛屼絾涓氬姟鐩爣瑕佺瓑缁堝瑁佸喅鈥濇椂琚鍒や负澶辫触銆?
CompositeRouteSucceeded bool
TaskActionUsed map[int]int
EntriesVersion int
SeenSlotQueries map[string]struct{}
// RequiredCompositeTool 琛ㄧず鏈疆绛栫暐瑕佹眰鈥滃繀椤昏嚦灏戞垚鍔熶竴娆♀€濈殑澶嶅悎宸ュ叿銆?
// 鍙栧€肩害瀹氾細"" | "SpreadEven" | "MinContextSwitch"銆?
RequiredCompositeTool string
// CompositeToolCalled 璁板綍澶嶅悎宸ュ叿鏄惁鑷冲皯璋冪敤杩囦竴娆★紙涓嶅尯鍒嗘垚鍔熷け璐ワ級銆?
CompositeToolCalled map[string]bool
// CompositeToolSuccess 璁板綍澶嶅悎宸ュ叿鏄惁鑷冲皯鎴愬姛杩囦竴娆°€?
CompositeToolSuccess map[string]bool
SlicePlan RefineSlicePlan
Objective RefineObjective
WorksetTaskIDs []int
WorksetCursor int
CurrentTaskID int
CurrentTaskAttempt int
LastFailedCallSignature string
OriginOrderMap map[int]int
// 4) 缁堝鐘舵€?
HardCheck HardCheckReport
// 5) 鏈€缁堣緭鍑?
FinalSummary string
Completed bool
}
// NewScheduleRefineState 鍩轰簬涓婁竴鐗堥瑙堝揩鐓у垵濮嬪寲鐘舵€併€?
//
// 鑱岃矗杈圭晫锛?
// 1. 璐熻矗鍒濆鍖栭绠椼€佷笂涓嬫枃瀛楁涓庡彲鍙樼姸鎬佸鍣紱
// 2. 璐熻矗鎷疯礉 preview 鏁版嵁锛岄伩鍏嶈法璇锋眰寮曠敤姹℃煋锛?
// 3. 涓嶈礋璐e仛浠讳綍璋冨害鍔ㄤ綔銆?
func NewScheduleRefineState(traceID string, userID int, conversationID string, userMessage string, preview *model.SchedulePlanPreviewCache) *ScheduleRefineState {
now := nowToMinute()
st := &ScheduleRefineState{
TraceID: strings.TrimSpace(traceID),
UserID: userID,
ConversationID: strings.TrimSpace(conversationID),
UserMessage: strings.TrimSpace(userMessage),
RequestNow: now,
RequestNowText: now.In(loadLocation()).Format(datetimeLayout),
PlanMax: defaultPlanMax,
PerTaskBudget: defaultPerTaskBudget,
ExecuteMax: defaultExecuteMax,
ReplanMax: defaultReplanMax,
CompositeRetryMax: defaultCompositeRetry,
RepairReserve: defaultRepairReserve,
MaxRounds: defaultExecuteMax + defaultRepairReserve,
ActionLogs: make([]string, 0, 32),
ObservationHistory: make([]ReactRoundObservation, 0, 24),
TaskActionUsed: make(map[int]int),
SeenSlotQueries: make(map[string]struct{}),
OriginOrderMap: make(map[int]int),
CompositeToolCalled: map[string]bool{
"SpreadEven": false,
"MinContextSwitch": false,
},
CompositeToolSuccess: map[string]bool{
"SpreadEven": false,
"MinContextSwitch": false,
},
CurrentPlan: PlannerPlan{
Summary: "initialized, waiting for planner output",
},
SlicePlan: RefineSlicePlan{
Reason: "灏氭湭鍒囩墖",
},
}
if preview == nil {
return st
}
st.TaskClassIDs = append([]int(nil), preview.TaskClassIDs...)
st.InitialHybridEntries = cloneHybridEntries(preview.HybridEntries)
st.HybridEntries = cloneHybridEntries(preview.HybridEntries)
st.AllocatedItems = cloneTaskClassItems(preview.AllocatedItems)
st.CandidatePlans = cloneWeekSchedules(preview.CandidatePlans)
st.OriginOrderMap = buildOriginOrderMap(st.HybridEntries)
return st
}
func loadLocation() *time.Location {
return agentshared.ShanghaiLocation()
}
func nowToMinute() time.Time {
return agentshared.NowToMinute()
}
func cloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry {
return agentshared.CloneHybridEntries(src)
}
func cloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem {
return agentshared.CloneTaskClassItems(src)
}
func cloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule {
return agentshared.CloneWeekSchedules(src)
}
// buildOriginOrderMap 鏋勫缓 suggested 浠诲姟鐨勫垵濮嬮『搴忓熀绾匡紙task_item_id -> rank锛夈€?
func buildOriginOrderMap(entries []model.HybridScheduleEntry) map[int]int {
orderMap := make(map[int]int)
if len(entries) == 0 {
return orderMap
}
suggested := make([]model.HybridScheduleEntry, 0, len(entries))
for _, entry := range entries {
if isMovableSuggestedTask(entry) {
suggested = append(suggested, entry)
}
}
sort.SliceStable(suggested, func(i, j int) bool {
left := suggested[i]
right := suggested[j]
if left.Week != right.Week {
return left.Week < right.Week
}
if left.DayOfWeek != right.DayOfWeek {
return left.DayOfWeek < right.DayOfWeek
}
if left.SectionFrom != right.SectionFrom {
return left.SectionFrom < right.SectionFrom
}
if left.SectionTo != right.SectionTo {
return left.SectionTo < right.SectionTo
}
return left.TaskItemID < right.TaskItemID
})
for i, entry := range suggested {
orderMap[entry.TaskItemID] = i + 1
}
return orderMap
}
// FinalHardCheckPassed 鍒ゆ柇鈥滄渶缁堢粓瀹♀€濇槸鍚︽暣浣撻€氳繃銆?
//
// 鑱岃矗杈圭晫锛?
// 1. 璐熻矗鑱氬悎 physics/order/intent 涓夌被纭牎楠岀粨鏋滐紝缁欐湇鍔″眰涓庢€荤粨闃舵缁熶竴澶嶇敤锛?
// 2. 涓嶈礋璐hЕ鍙戠粓瀹★紝涔熶笉璐熻矗鎺ㄥ淇鍔ㄤ綔锛?
// 3. nil state 瑙嗕负鏈€氳繃锛岄伩鍏嶄笂灞傛妸缂哄け缁撴灉璇垽涓烘垚鍔熴€?
func FinalHardCheckPassed(st *ScheduleRefineState) bool {
if st == nil {
return false
}
return st.HardCheck.PhysicsPassed && st.HardCheck.OrderPassed && st.HardCheck.IntentPassed
}
func isMovableSuggestedTask(entry model.HybridScheduleEntry) bool {
if strings.TrimSpace(entry.Status) != "suggested" || entry.TaskItemID <= 0 {
return false
}
if strings.EqualFold(strings.TrimSpace(entry.Type), "course") {
return false
}
return true
}

View File

@@ -2,8 +2,13 @@ package agentnode
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/components/tool"
@@ -116,3 +121,384 @@ func (n *QuickNoteNodes) NextAfterPersist(ctx context.Context, st *agentmodel.Qu
}
return compose.END, nil
}
// Intent 负责“意图识别 + 聚合规划 + 时间校验”。
//
// 职责边界:
// 1. 负责判断本次请求是否属于随口记;
// 2. 负责把模型规划结果回填到 state
// 3. 负责做最后一层本地时间硬校验,避免非法时间被静默写成 NULL
// 4. 不负责真正写库。
func (n *QuickNoteNodes) Intent(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) {
if st == nil {
return nil, errors.New("quick note graph: nil state in intent node")
}
// 1. 若上游路由已经高置信命中 quick_note则直接进入单次聚合规划。
// 1.1 目的:尽量把“标题 / 时间 / 优先级 / banter”压缩到一次模型往返内
// 1.2 失败处理:若聚合规划失败,不中断整条链路,而是回退到本地兜底,保证可用性优先。
if n.input.SkipIntentVerification {
n.emitStage("quick_note.intent.analyzing", "已由上游路由判定为任务请求,跳过二次意图判断。")
st.IsQuickNoteIntent = true
st.IntentJudgeReason = "上游路由已命中 quick_note跳过二次意图判定"
st.PlannedBySingleCall = true
n.emitStage("quick_note.plan.generating", "正在一次性生成时间归一化、优先级与回复润色。")
plan, planErr := planQuickNoteInSingleCall(ctx, n.input.Model, st.RequestNowText, st.RequestNow, st.UserInput)
if planErr != nil {
st.IntentJudgeReason += ";聚合规划失败,回退本地兜底"
} else {
if strings.TrimSpace(plan.Title) != "" {
st.ExtractedTitle = strings.TrimSpace(plan.Title)
}
if plan.Deadline != nil {
st.ExtractedDeadline = plan.Deadline
}
st.ExtractedDeadlineText = strings.TrimSpace(plan.DeadlineText)
if plan.UrgencyThreshold != nil {
st.ExtractedUrgencyThreshold = normalizeUrgencyThreshold(plan.UrgencyThreshold, plan.Deadline)
}
if agentmodel.IsValidTaskPriority(plan.PriorityGroup) {
st.ExtractedPriority = plan.PriorityGroup
st.ExtractedPriorityReason = strings.TrimSpace(plan.PriorityReason)
}
st.ExtractedBanter = strings.TrimSpace(plan.Banter)
}
// 1.3 如果聚合规划没能给出标题,则回退到本地标题抽取,避免后续 persist 节点拿到空标题。
if strings.TrimSpace(st.ExtractedTitle) == "" {
st.ExtractedTitle = deriveQuickNoteTitleFromInput(st.UserInput)
}
// 1.4 最后一定要做一轮本地时间硬校验。
// 1.4.1 原因:模型即使给了时间,也可能和用户原句不一致,或者用户原句本身就是非法时间;
// 1.4.2 若检测到“用户给了时间线索但格式非法”,直接退出图并给用户明确修正提示。
n.emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。")
userDeadline, userHasTimeHint, userDeadlineErr := parseOptionalDeadlineFromUserInput(st.UserInput, st.RequestNow)
if userHasTimeHint && userDeadlineErr != nil {
st.DeadlineValidationError = userDeadlineErr.Error()
st.AssistantReply = "我识别到你给了时间信息但这个时间格式我没法准确解析请改成例如2026-03-20 18:30、明天下午3点、下周一上午9点。"
n.emitStage("quick_note.failed", "时间校验失败,未执行写入。")
return st, nil
}
if userDeadline != nil {
st.ExtractedDeadline = userDeadline
st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput)
}
return st, nil
}
// 2. 常规路径:先做一次意图识别,再做本地时间硬校验。
n.emitStage("quick_note.intent.analyzing", "正在分析用户输入是否属于任务安排请求。")
parsed, callErr := agentllm.IdentifyQuickNoteIntent(ctx, n.input.Model, st.RequestNowText, st.UserInput)
if callErr != nil {
// 2.1 这里不直接返回 error而是把它视为“本次未能确认是 quick note”交给上层回退普通聊天。
st.IsQuickNoteIntent = false
st.IntentJudgeReason = "意图识别失败,回退普通聊天"
return st, nil
}
st.IsQuickNoteIntent = parsed.IsQuickNote
st.IntentJudgeReason = strings.TrimSpace(parsed.Reason)
if !st.IsQuickNoteIntent {
return st, nil
}
title := strings.TrimSpace(parsed.Title)
if title == "" {
title = strings.TrimSpace(st.UserInput)
}
st.ExtractedTitle = title
n.emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。")
// 2.2 先尝试吃模型返回的 deadline_at用于减少后续重复推理。
st.ExtractedDeadlineText = strings.TrimSpace(parsed.DeadlineAt)
if st.ExtractedDeadlineText != "" {
if deadline, deadlineErr := parseOptionalDeadlineWithNow(st.ExtractedDeadlineText, st.RequestNow); deadlineErr == nil {
st.ExtractedDeadline = deadline
}
}
// 2.3 再强制对用户原句做一次时间线索校验。
userDeadline, userHasTimeHint, userDeadlineErr := parseOptionalDeadlineFromUserInput(st.UserInput, st.RequestNow)
if userHasTimeHint && userDeadlineErr != nil {
st.DeadlineValidationError = userDeadlineErr.Error()
st.AssistantReply = "我识别到你给了时间信息但这个时间格式我没法准确解析请改成例如2026-03-20 18:30、明天下午3点、下周一上午9点。"
n.emitStage("quick_note.failed", "时间校验失败,未执行写入。")
return st, nil
}
// 2.4 若模型没提到 deadline但用户原句能解析出来则以用户原句为准补齐。
if st.ExtractedDeadline == nil && userDeadline != nil {
st.ExtractedDeadline = userDeadline
if st.ExtractedDeadlineText == "" {
st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput)
}
}
return st, nil
}
// Priority 负责“优先级评估”。
//
// 职责边界:
// 1. 负责在 intent 节点之后补齐 priority_group
// 2. 若聚合规划已经给出合法优先级,则直接复用,不再重复调用模型;
// 3. 若模型评估失败,则使用本地兜底策略,保证链路继续可走;
// 4. 不负责写库。
func (n *QuickNoteNodes) Priority(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) {
if st == nil {
return nil, errors.New("quick note graph: nil state in priority node")
}
if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" {
return st, nil
}
// 1. 聚合规划已经给出合法优先级时,直接复用,避免重复调模型。
if agentmodel.IsValidTaskPriority(st.ExtractedPriority) {
if strings.TrimSpace(st.ExtractedPriorityReason) == "" {
st.ExtractedPriorityReason = "复用聚合规划优先级"
}
n.emitStage("quick_note.priority.evaluating", "已复用聚合规划结果中的优先级。")
return st, nil
}
// 2. 单请求聚合路径若没有给出合法 priority则直接走本地兜底优先保证低时延。
if n.input.SkipIntentVerification || st.PlannedBySingleCall {
st.ExtractedPriority = fallbackPriority(st)
st.ExtractedPriorityReason = "聚合规划未给出合法优先级,使用本地兜底"
n.emitStage("quick_note.priority.evaluating", "聚合优先级缺失,已使用本地兜底。")
return st, nil
}
n.emitStage("quick_note.priority.evaluating", "正在评估任务优先级。")
deadlineText := "无"
if st.ExtractedDeadline != nil {
deadlineText = formatQuickNoteTimeToMinute(*st.ExtractedDeadline)
}
deadlineClue := strings.TrimSpace(st.ExtractedDeadlineText)
if deadlineClue == "" {
deadlineClue = "无"
}
parsed, callErr := agentllm.PlanQuickNotePriority(ctx, n.input.Model, st.RequestNowText, st.ExtractedTitle, st.UserInput, deadlineClue, deadlineText)
if callErr != nil {
st.ExtractedPriority = fallbackPriority(st)
st.ExtractedPriorityReason = "优先级评估失败,使用兜底策略"
return st, nil
}
if parsed == nil || !agentmodel.IsValidTaskPriority(parsed.PriorityGroup) {
st.ExtractedPriority = fallbackPriority(st)
st.ExtractedPriorityReason = "优先级结果异常,使用兜底策略"
return st, nil
}
st.ExtractedPriority = parsed.PriorityGroup
st.ExtractedPriorityReason = strings.TrimSpace(parsed.Reason)
if strings.TrimSpace(parsed.UrgencyThresholdAt) != "" {
urgencyThreshold, thresholdErr := parseOptionalDeadlineWithNow(strings.TrimSpace(parsed.UrgencyThresholdAt), st.RequestNow)
if thresholdErr == nil {
st.ExtractedUrgencyThreshold = normalizeUrgencyThreshold(urgencyThreshold, st.ExtractedDeadline)
}
}
return st, nil
}
// Persist 负责“调工具写库 + 有限次重试状态回填”。
//
// 职责边界:
// 1. 负责把 state 中已提取出的标题、时间、优先级组装成工具入参;
// 2. 负责调用 createTaskTool 执行真正写库;
// 3. 负责把成功/失败结果回填到 state供后续分支与回复使用
// 4. 不负责最终回复润色,不负责 service 层的 Redis 与持久化收尾。
func (n *QuickNoteNodes) Persist(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) {
if st == nil {
return nil, errors.New("quick note graph: nil state in persist node")
}
if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" {
return st, nil
}
n.emitStage("quick_note.persisting", "正在写入任务数据。")
priority := st.ExtractedPriority
if !agentmodel.IsValidTaskPriority(priority) {
priority = fallbackPriority(st)
st.ExtractedPriority = priority
}
deadlineText := ""
if st.ExtractedDeadline != nil {
deadlineText = st.ExtractedDeadline.In(quickNoteLocation()).Format(time.RFC3339)
}
urgencyThresholdText := ""
if st.ExtractedUrgencyThreshold != nil {
urgencyThresholdText = st.ExtractedUrgencyThreshold.In(quickNoteLocation()).Format(time.RFC3339)
}
toolInput := QuickNoteCreateTaskToolInput{
Title: st.ExtractedTitle,
PriorityGroup: priority,
DeadlineAt: deadlineText,
UrgencyThresholdAt: urgencyThresholdText,
}
rawInput, marshalErr := json.Marshal(toolInput)
if marshalErr != nil {
st.RecordToolError("构造工具参数失败: " + marshalErr.Error())
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,记录任务时参数处理失败,请稍后重试。"
n.emitStage("quick_note.failed", "参数构造失败,未完成写入。")
}
return st, nil
}
rawOutput, invokeErr := n.createTaskTool.InvokableRun(ctx, string(rawInput))
if invokeErr != nil {
st.RecordToolError(invokeErr.Error())
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,我尝试了多次仍未能成功记录这条任务,请稍后再试。"
n.emitStage("quick_note.failed", "多次重试后仍未完成写入。")
}
return st, nil
}
toolOutput, parseErr := agentllm.ParseJSONObject[QuickNoteCreateTaskToolOutput](rawOutput)
if parseErr != nil {
st.RecordToolError("解析工具返回失败: " + parseErr.Error())
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,我拿到了异常结果,没能确认任务是否记录成功,请稍后再试。"
n.emitStage("quick_note.failed", "结果解析异常,无法确认写入结果。")
}
return st, nil
}
if toolOutput.TaskID <= 0 {
st.RecordToolError(fmt.Sprintf("工具返回非法 task_id=%d", toolOutput.TaskID))
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,这次我没能确认任务写入成功,请再发一次我立刻补上。"
n.emitStage("quick_note.failed", "写入结果缺少有效 task_id已终止成功回包。")
}
return st, nil
}
// 1. 只有拿到有效 task_id才视为真正写入成功
// 2. 这样可以避免出现“返回成功文案,但数据库里根本没记录”的假成功。
st.RecordToolSuccess(toolOutput.TaskID)
if strings.TrimSpace(toolOutput.Title) != "" {
st.ExtractedTitle = strings.TrimSpace(toolOutput.Title)
}
if agentmodel.IsValidTaskPriority(toolOutput.PriorityGroup) {
st.ExtractedPriority = toolOutput.PriorityGroup
}
reply := strings.TrimSpace(toolOutput.Message)
if reply == "" {
reply = fmt.Sprintf("已为你记录:%s%s", st.ExtractedTitle, agentmodel.PriorityLabelCN(st.ExtractedPriority))
}
st.AssistantReply = reply
n.emitStage("quick_note.persisted", "任务写入成功,正在组织回复内容。")
return st, nil
}
type quickNotePlannedResult struct {
Title string
Deadline *time.Time
DeadlineText string
UrgencyThreshold *time.Time
UrgencyThresholdText string
PriorityGroup int
PriorityReason string
Banter string
}
// planQuickNoteInSingleCall 在一次模型调用里完成“时间 / 优先级 / banter”聚合规划。
func planQuickNoteInSingleCall(ctx context.Context, chatModel *ark.ChatModel, nowText string, now time.Time, userInput string) (*quickNotePlannedResult, error) {
parsed, err := agentllm.PlanQuickNoteInSingleCall(ctx, chatModel, nowText, userInput)
if err != nil {
return nil, err
}
result := &quickNotePlannedResult{
Title: strings.TrimSpace(parsed.Title),
DeadlineText: strings.TrimSpace(parsed.DeadlineAt),
UrgencyThresholdText: strings.TrimSpace(parsed.UrgencyThresholdAt),
PriorityGroup: parsed.PriorityGroup,
PriorityReason: strings.TrimSpace(parsed.PriorityReason),
Banter: strings.TrimSpace(parsed.Banter),
}
if result.Banter != "" {
if idx := strings.Index(result.Banter, "\n"); idx >= 0 {
result.Banter = strings.TrimSpace(result.Banter[:idx])
}
}
if result.DeadlineText != "" {
if deadline, deadlineErr := parseOptionalDeadlineWithNow(result.DeadlineText, now); deadlineErr == nil {
result.Deadline = deadline
}
}
if result.UrgencyThresholdText != "" {
if urgencyThreshold, thresholdErr := parseOptionalDeadlineWithNow(result.UrgencyThresholdText, now); thresholdErr == nil {
result.UrgencyThreshold = normalizeUrgencyThreshold(urgencyThreshold, result.Deadline)
}
}
return result, nil
}
func normalizeUrgencyThreshold(threshold *time.Time, deadline *time.Time) *time.Time {
if threshold == nil {
return nil
}
if deadline == nil {
return threshold
}
if threshold.After(*deadline) {
normalized := *deadline
return &normalized
}
return threshold
}
func fallbackPriority(st *agentmodel.QuickNoteState) int {
if st == nil {
return agentmodel.QuickNotePrioritySimpleNotImportant
}
if st.ExtractedDeadline != nil {
if time.Until(*st.ExtractedDeadline) <= 48*time.Hour {
return agentmodel.QuickNotePriorityImportantUrgent
}
return agentmodel.QuickNotePriorityImportantNotUrgent
}
return agentmodel.QuickNotePrioritySimpleNotImportant
}
// deriveQuickNoteTitleFromInput 在“跳过二次意图判定”场景下,从用户原句提取任务标题。
func deriveQuickNoteTitleFromInput(userInput string) string {
text := strings.TrimSpace(userInput)
if text == "" {
return "这条任务"
}
prefixes := []string{
"请帮我", "麻烦帮我", "麻烦你", "帮我", "提醒我", "请提醒我", "记一个", "记个", "帮我记一个",
}
for _, prefix := range prefixes {
if strings.HasPrefix(text, prefix) {
text = strings.TrimSpace(strings.TrimPrefix(text, prefix))
break
}
}
suffixSeparators := []string{
",记得", ",记得", ",到时候", ",到时候", " 到时候", ",别忘了", ",别忘了", "。记得",
}
for _, sep := range suffixSeparators {
if idx := strings.Index(text, sep); idx > 0 {
text = strings.TrimSpace(text[:idx])
break
}
}
text = strings.Trim(text, ",。?! ")
if text == "" {
return strings.TrimSpace(userInput)
}
return text
}

View File

@@ -1,395 +0,0 @@
package agentnode
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
"github.com/cloudwego/eino-ext/components/model/ark"
)
// Intent 负责“意图识别 + 聚合规划 + 时间校验”。
//
// 职责边界:
// 1. 负责判断本次请求是否属于随口记;
// 2. 负责把模型规划结果回填到 state
// 3. 负责做最后一层本地时间硬校验,避免非法时间被静默写成 NULL
// 4. 不负责真正写库。
func (n *QuickNoteNodes) Intent(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) {
if st == nil {
return nil, errors.New("quick note graph: nil state in intent node")
}
// 1. 若上游路由已经高置信命中 quick_note则直接进入单次聚合规划。
// 1.1 目的:尽量把“标题 / 时间 / 优先级 / banter”压缩到一次模型往返内
// 1.2 失败处理:若聚合规划失败,不中断整条链路,而是回退到本地兜底,保证可用性优先。
if n.input.SkipIntentVerification {
n.emitStage("quick_note.intent.analyzing", "已由上游路由判定为任务请求,跳过二次意图判断。")
st.IsQuickNoteIntent = true
st.IntentJudgeReason = "上游路由已命中 quick_note跳过二次意图判定"
st.PlannedBySingleCall = true
n.emitStage("quick_note.plan.generating", "正在一次性生成时间归一化、优先级与回复润色。")
plan, planErr := planQuickNoteInSingleCall(ctx, n.input.Model, st.RequestNowText, st.RequestNow, st.UserInput)
if planErr != nil {
st.IntentJudgeReason += ";聚合规划失败,回退本地兜底"
} else {
if strings.TrimSpace(plan.Title) != "" {
st.ExtractedTitle = strings.TrimSpace(plan.Title)
}
if plan.Deadline != nil {
st.ExtractedDeadline = plan.Deadline
}
st.ExtractedDeadlineText = strings.TrimSpace(plan.DeadlineText)
if plan.UrgencyThreshold != nil {
st.ExtractedUrgencyThreshold = normalizeUrgencyThreshold(plan.UrgencyThreshold, plan.Deadline)
}
if agentmodel.IsValidTaskPriority(plan.PriorityGroup) {
st.ExtractedPriority = plan.PriorityGroup
st.ExtractedPriorityReason = strings.TrimSpace(plan.PriorityReason)
}
st.ExtractedBanter = strings.TrimSpace(plan.Banter)
}
// 1.3 如果聚合规划没能给出标题,则回退到本地标题抽取,避免后续 persist 节点拿到空标题。
if strings.TrimSpace(st.ExtractedTitle) == "" {
st.ExtractedTitle = deriveQuickNoteTitleFromInput(st.UserInput)
}
// 1.4 最后一定要做一轮本地时间硬校验。
// 1.4.1 原因:模型即使给了时间,也可能和用户原句不一致,或者用户原句本身就是非法时间;
// 1.4.2 若检测到“用户给了时间线索但格式非法”,直接退出图并给用户明确修正提示。
n.emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。")
userDeadline, userHasTimeHint, userDeadlineErr := parseOptionalDeadlineFromUserInput(st.UserInput, st.RequestNow)
if userHasTimeHint && userDeadlineErr != nil {
st.DeadlineValidationError = userDeadlineErr.Error()
st.AssistantReply = "我识别到你给了时间信息但这个时间格式我没法准确解析请改成例如2026-03-20 18:30、明天下午3点、下周一上午9点。"
n.emitStage("quick_note.failed", "时间校验失败,未执行写入。")
return st, nil
}
if userDeadline != nil {
st.ExtractedDeadline = userDeadline
st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput)
}
return st, nil
}
// 2. 常规路径:先做一次意图识别,再做本地时间硬校验。
n.emitStage("quick_note.intent.analyzing", "正在分析用户输入是否属于任务安排请求。")
parsed, callErr := agentllm.IdentifyQuickNoteIntent(ctx, n.input.Model, st.RequestNowText, st.UserInput)
if callErr != nil {
// 2.1 这里不直接返回 error而是把它视为“本次未能确认是 quick note”交给上层回退普通聊天。
st.IsQuickNoteIntent = false
st.IntentJudgeReason = "意图识别失败,回退普通聊天"
return st, nil
}
st.IsQuickNoteIntent = parsed.IsQuickNote
st.IntentJudgeReason = strings.TrimSpace(parsed.Reason)
if !st.IsQuickNoteIntent {
return st, nil
}
title := strings.TrimSpace(parsed.Title)
if title == "" {
title = strings.TrimSpace(st.UserInput)
}
st.ExtractedTitle = title
n.emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。")
// 2.2 先尝试吃模型返回的 deadline_at用于减少后续重复推理。
st.ExtractedDeadlineText = strings.TrimSpace(parsed.DeadlineAt)
if st.ExtractedDeadlineText != "" {
if deadline, deadlineErr := parseOptionalDeadlineWithNow(st.ExtractedDeadlineText, st.RequestNow); deadlineErr == nil {
st.ExtractedDeadline = deadline
}
}
// 2.3 再强制对用户原句做一次时间线索校验。
userDeadline, userHasTimeHint, userDeadlineErr := parseOptionalDeadlineFromUserInput(st.UserInput, st.RequestNow)
if userHasTimeHint && userDeadlineErr != nil {
st.DeadlineValidationError = userDeadlineErr.Error()
st.AssistantReply = "我识别到你给了时间信息但这个时间格式我没法准确解析请改成例如2026-03-20 18:30、明天下午3点、下周一上午9点。"
n.emitStage("quick_note.failed", "时间校验失败,未执行写入。")
return st, nil
}
// 2.4 若模型没提到 deadline但用户原句能解析出来则以用户原句为准补齐。
if st.ExtractedDeadline == nil && userDeadline != nil {
st.ExtractedDeadline = userDeadline
if st.ExtractedDeadlineText == "" {
st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput)
}
}
return st, nil
}
// Priority 负责“优先级评估”。
//
// 职责边界:
// 1. 负责在 intent 节点之后补齐 priority_group
// 2. 若聚合规划已经给出合法优先级,则直接复用,不再重复调用模型;
// 3. 若模型评估失败,则使用本地兜底策略,保证链路继续可走;
// 4. 不负责写库。
func (n *QuickNoteNodes) Priority(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) {
if st == nil {
return nil, errors.New("quick note graph: nil state in priority node")
}
if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" {
return st, nil
}
// 1. 聚合规划已经给出合法优先级时,直接复用,避免重复调模型。
if agentmodel.IsValidTaskPriority(st.ExtractedPriority) {
if strings.TrimSpace(st.ExtractedPriorityReason) == "" {
st.ExtractedPriorityReason = "复用聚合规划优先级"
}
n.emitStage("quick_note.priority.evaluating", "已复用聚合规划结果中的优先级。")
return st, nil
}
// 2. 单请求聚合路径若没有给出合法 priority则直接走本地兜底优先保证低时延。
if n.input.SkipIntentVerification || st.PlannedBySingleCall {
st.ExtractedPriority = fallbackPriority(st)
st.ExtractedPriorityReason = "聚合规划未给出合法优先级,使用本地兜底"
n.emitStage("quick_note.priority.evaluating", "聚合优先级缺失,已使用本地兜底。")
return st, nil
}
n.emitStage("quick_note.priority.evaluating", "正在评估任务优先级。")
deadlineText := "无"
if st.ExtractedDeadline != nil {
deadlineText = formatQuickNoteTimeToMinute(*st.ExtractedDeadline)
}
deadlineClue := strings.TrimSpace(st.ExtractedDeadlineText)
if deadlineClue == "" {
deadlineClue = "无"
}
parsed, callErr := agentllm.PlanQuickNotePriority(ctx, n.input.Model, st.RequestNowText, st.ExtractedTitle, st.UserInput, deadlineClue, deadlineText)
if callErr != nil {
st.ExtractedPriority = fallbackPriority(st)
st.ExtractedPriorityReason = "优先级评估失败,使用兜底策略"
return st, nil
}
if parsed == nil || !agentmodel.IsValidTaskPriority(parsed.PriorityGroup) {
st.ExtractedPriority = fallbackPriority(st)
st.ExtractedPriorityReason = "优先级结果异常,使用兜底策略"
return st, nil
}
st.ExtractedPriority = parsed.PriorityGroup
st.ExtractedPriorityReason = strings.TrimSpace(parsed.Reason)
if strings.TrimSpace(parsed.UrgencyThresholdAt) != "" {
urgencyThreshold, thresholdErr := parseOptionalDeadlineWithNow(strings.TrimSpace(parsed.UrgencyThresholdAt), st.RequestNow)
if thresholdErr == nil {
st.ExtractedUrgencyThreshold = normalizeUrgencyThreshold(urgencyThreshold, st.ExtractedDeadline)
}
}
return st, nil
}
// Persist 负责“调工具写库 + 有限次重试状态回填”。
//
// 职责边界:
// 1. 负责把 state 中已提取出的标题、时间、优先级组装成工具入参;
// 2. 负责调用 createTaskTool 执行真正写库;
// 3. 负责把成功/失败结果回填到 state供后续分支与回复使用
// 4. 不负责最终回复润色,不负责 service 层的 Redis 与持久化收尾。
func (n *QuickNoteNodes) Persist(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) {
if st == nil {
return nil, errors.New("quick note graph: nil state in persist node")
}
if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" {
return st, nil
}
n.emitStage("quick_note.persisting", "正在写入任务数据。")
priority := st.ExtractedPriority
if !agentmodel.IsValidTaskPriority(priority) {
priority = fallbackPriority(st)
st.ExtractedPriority = priority
}
deadlineText := ""
if st.ExtractedDeadline != nil {
deadlineText = st.ExtractedDeadline.In(quickNoteLocation()).Format(time.RFC3339)
}
urgencyThresholdText := ""
if st.ExtractedUrgencyThreshold != nil {
urgencyThresholdText = st.ExtractedUrgencyThreshold.In(quickNoteLocation()).Format(time.RFC3339)
}
toolInput := QuickNoteCreateTaskToolInput{
Title: st.ExtractedTitle,
PriorityGroup: priority,
DeadlineAt: deadlineText,
UrgencyThresholdAt: urgencyThresholdText,
}
rawInput, marshalErr := json.Marshal(toolInput)
if marshalErr != nil {
st.RecordToolError("构造工具参数失败: " + marshalErr.Error())
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,记录任务时参数处理失败,请稍后重试。"
n.emitStage("quick_note.failed", "参数构造失败,未完成写入。")
}
return st, nil
}
rawOutput, invokeErr := n.createTaskTool.InvokableRun(ctx, string(rawInput))
if invokeErr != nil {
st.RecordToolError(invokeErr.Error())
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,我尝试了多次仍未能成功记录这条任务,请稍后再试。"
n.emitStage("quick_note.failed", "多次重试后仍未完成写入。")
}
return st, nil
}
toolOutput, parseErr := agentllm.ParseJSONObject[QuickNoteCreateTaskToolOutput](rawOutput)
if parseErr != nil {
st.RecordToolError("解析工具返回失败: " + parseErr.Error())
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,我拿到了异常结果,没能确认任务是否记录成功,请稍后再试。"
n.emitStage("quick_note.failed", "结果解析异常,无法确认写入结果。")
}
return st, nil
}
if toolOutput.TaskID <= 0 {
st.RecordToolError(fmt.Sprintf("工具返回非法 task_id=%d", toolOutput.TaskID))
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,这次我没能确认任务写入成功,请再发一次我立刻补上。"
n.emitStage("quick_note.failed", "写入结果缺少有效 task_id已终止成功回包。")
}
return st, nil
}
// 1. 只有拿到有效 task_id才视为真正写入成功
// 2. 这样可以避免出现“返回成功文案,但数据库里根本没记录”的假成功。
st.RecordToolSuccess(toolOutput.TaskID)
if strings.TrimSpace(toolOutput.Title) != "" {
st.ExtractedTitle = strings.TrimSpace(toolOutput.Title)
}
if agentmodel.IsValidTaskPriority(toolOutput.PriorityGroup) {
st.ExtractedPriority = toolOutput.PriorityGroup
}
reply := strings.TrimSpace(toolOutput.Message)
if reply == "" {
reply = fmt.Sprintf("已为你记录:%s%s", st.ExtractedTitle, agentmodel.PriorityLabelCN(st.ExtractedPriority))
}
st.AssistantReply = reply
n.emitStage("quick_note.persisted", "任务写入成功,正在组织回复内容。")
return st, nil
}
type quickNotePlannedResult struct {
Title string
Deadline *time.Time
DeadlineText string
UrgencyThreshold *time.Time
UrgencyThresholdText string
PriorityGroup int
PriorityReason string
Banter string
}
// planQuickNoteInSingleCall 在一次模型调用里完成“时间 / 优先级 / banter”聚合规划。
func planQuickNoteInSingleCall(ctx context.Context, chatModel *ark.ChatModel, nowText string, now time.Time, userInput string) (*quickNotePlannedResult, error) {
parsed, err := agentllm.PlanQuickNoteInSingleCall(ctx, chatModel, nowText, userInput)
if err != nil {
return nil, err
}
result := &quickNotePlannedResult{
Title: strings.TrimSpace(parsed.Title),
DeadlineText: strings.TrimSpace(parsed.DeadlineAt),
UrgencyThresholdText: strings.TrimSpace(parsed.UrgencyThresholdAt),
PriorityGroup: parsed.PriorityGroup,
PriorityReason: strings.TrimSpace(parsed.PriorityReason),
Banter: strings.TrimSpace(parsed.Banter),
}
if result.Banter != "" {
if idx := strings.Index(result.Banter, "\n"); idx >= 0 {
result.Banter = strings.TrimSpace(result.Banter[:idx])
}
}
if result.DeadlineText != "" {
if deadline, deadlineErr := parseOptionalDeadlineWithNow(result.DeadlineText, now); deadlineErr == nil {
result.Deadline = deadline
}
}
if result.UrgencyThresholdText != "" {
if urgencyThreshold, thresholdErr := parseOptionalDeadlineWithNow(result.UrgencyThresholdText, now); thresholdErr == nil {
result.UrgencyThreshold = normalizeUrgencyThreshold(urgencyThreshold, result.Deadline)
}
}
return result, nil
}
func normalizeUrgencyThreshold(threshold *time.Time, deadline *time.Time) *time.Time {
if threshold == nil {
return nil
}
if deadline == nil {
return threshold
}
if threshold.After(*deadline) {
normalized := *deadline
return &normalized
}
return threshold
}
func fallbackPriority(st *agentmodel.QuickNoteState) int {
if st == nil {
return agentmodel.QuickNotePrioritySimpleNotImportant
}
if st.ExtractedDeadline != nil {
if time.Until(*st.ExtractedDeadline) <= 48*time.Hour {
return agentmodel.QuickNotePriorityImportantUrgent
}
return agentmodel.QuickNotePriorityImportantNotUrgent
}
return agentmodel.QuickNotePrioritySimpleNotImportant
}
// deriveQuickNoteTitleFromInput 在“跳过二次意图判定”场景下,从用户原句提取任务标题。
func deriveQuickNoteTitleFromInput(userInput string) string {
text := strings.TrimSpace(userInput)
if text == "" {
return "这条任务"
}
prefixes := []string{
"请帮我", "麻烦帮我", "麻烦你", "帮我", "提醒我", "请提醒我", "记一个", "记个", "帮我记一个",
}
for _, prefix := range prefixes {
if strings.HasPrefix(text, prefix) {
text = strings.TrimSpace(strings.TrimPrefix(text, prefix))
break
}
}
suffixSeparators := []string{
",记得", ",记得", ",到时候", ",到时候", " 到时候", ",别忘了", ",别忘了", "。记得",
}
for _, sep := range suffixSeparators {
if idx := strings.Index(text, sep); idx > 0 {
text = strings.TrimSpace(text[:idx])
break
}
}
text = strings.Trim(text, ",。?! ")
if text == "" {
return strings.TrimSpace(userInput)
}
return text
}

View File

@@ -1,25 +1,69 @@
package agentnode
import (
agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm"
agentstream "github.com/LoveLosita/smartflow/backend/agent2/stream"
"context"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
agentrefine "github.com/LoveLosita/smartflow/backend/agent2/node/schedule_refine_impl"
"github.com/LoveLosita/smartflow/backend/model"
)
// ScheduleRefineNodeDeps 描述“连续微调排程”节点层公共依赖。
type ScheduleRefineNodeDeps struct {
LLM *agentllm.Client
StageEmitter agentstream.StageEmitter
// ScheduleRefineState is the node-layer alias for refine state.
type ScheduleRefineState = agentrefine.ScheduleRefineState
// ScheduleRefineGraphRunInput is the node-layer alias for refine graph input.
type ScheduleRefineGraphRunInput = agentrefine.ScheduleRefineGraphRunInput
// NewScheduleRefineState creates refine state from the previous preview snapshot.
func NewScheduleRefineState(traceID string, userID int, conversationID string, userMessage string, preview *model.SchedulePlanPreviewCache) *ScheduleRefineState {
return agentrefine.NewScheduleRefineState(traceID, userID, conversationID, userMessage, preview)
}
// ScheduleRefineNodes 是“连续微调排程”节点逻辑容器。
// FinalHardCheckPassed reports whether the final refine hard check passed.
func FinalHardCheckPassed(st *ScheduleRefineState) bool {
return agentrefine.FinalHardCheckPassed(st)
}
// ScheduleRefineNodes is a temporary compatibility facade.
// The real refine implementation still lives in schedule_refine_impl until the next split round lands.
type ScheduleRefineNodes struct {
deps ScheduleRefineNodeDeps
input ScheduleRefineGraphRunInput
}
// NewScheduleRefineNodes 创建连续微调节点容器。
func NewScheduleRefineNodes(deps ScheduleRefineNodeDeps) *ScheduleRefineNodes {
if deps.StageEmitter == nil {
deps.StageEmitter = agentstream.NoopStageEmitter()
}
return &ScheduleRefineNodes{deps: deps}
// NewScheduleRefineNodes stores the refine graph input.
func NewScheduleRefineNodes(input ScheduleRefineGraphRunInput) (*ScheduleRefineNodes, error) {
return &ScheduleRefineNodes{input: input}, nil
}
func (n *ScheduleRefineNodes) Contract(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) {
return st, nil
}
func (n *ScheduleRefineNodes) Plan(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) {
return st, nil
}
func (n *ScheduleRefineNodes) Slice(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) {
return st, nil
}
func (n *ScheduleRefineNodes) Route(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) {
return st, nil
}
func (n *ScheduleRefineNodes) React(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) {
return st, nil
}
func (n *ScheduleRefineNodes) HardCheck(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) {
return st, nil
}
func (n *ScheduleRefineNodes) Summary(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) {
return st, nil
}
// RunScheduleRefineGraph is kept as the single executable entry for refine.
func RunScheduleRefineGraph(ctx context.Context, input ScheduleRefineGraphRunInput) (*ScheduleRefineState, error) {
return agentrefine.RunScheduleRefineGraph(ctx, input)
}

View File

@@ -0,0 +1,85 @@
package schedulerefine
import (
"context"
"testing"
"github.com/LoveLosita/smartflow/backend/model"
)
func TestRefineToolSpreadEvenRespectsCanonicalRouteFilters(t *testing.T) {
entries := []model.HybridScheduleEntry{
{TaskItemID: 1, Name: "任务1", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, ContextTag: "A"},
// 1. 这里放一个更早周次的 existing 条目,用来把可查询窗口拉到 W11
// 2. 若复合工具内部丢了 week_filter/day_of_week就会优先落到更早的 W11D1而不是目标 W12D3。
{TaskItemID: 99, Name: "课程", Type: "course", Status: "existing", Week: 11, DayOfWeek: 5, SectionFrom: 11, SectionTo: 12, BlockForSuggested: true},
}
params := map[string]any{
"task_item_ids": []int{1},
"week_filter": []int{12},
"day_of_week": []int{3},
"allow_embed": false,
}
nextEntries, result := refineToolSpreadEven(entries, params, planningWindow{Enabled: false}, refineToolPolicy{
OriginOrderMap: map[int]int{1: 1},
})
if !result.Success {
t.Fatalf("SpreadEven 执行失败: %s", result.Result)
}
idx := findSuggestedByID(nextEntries, 1)
if idx < 0 {
t.Fatalf("未找到 task_item_id=1")
}
got := nextEntries[idx]
if got.Week != 12 || got.DayOfWeek != 3 {
t.Fatalf("期望复合工具严格遵守 week_filter/day_of_week实际落点=W%dD%d", got.Week, got.DayOfWeek)
}
}
func TestRunCompositeRouteNodeAllowsHandoffWithoutDeterministicObjective(t *testing.T) {
entries := []model.HybridScheduleEntry{
{TaskItemID: 11, Name: "任务11", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, ContextTag: "数学"},
{TaskItemID: 12, Name: "任务12", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, ContextTag: "算法"},
{TaskItemID: 13, Name: "任务13", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, ContextTag: "数学"},
}
st := &ScheduleRefineState{
UserMessage: "把这些任务按最少上下文切换整理一下",
HybridEntries: cloneHybridEntries(entries),
InitialHybridEntries: cloneHybridEntries(entries),
WorksetTaskIDs: []int{11, 12, 13},
RequiredCompositeTool: "MinContextSwitch",
CompositeRetryMax: 0,
ExecuteMax: 4,
OriginOrderMap: map[int]int{11: 1, 12: 2, 13: 3},
CompositeToolCalled: map[string]bool{
"SpreadEven": false,
"MinContextSwitch": false,
},
CompositeToolSuccess: map[string]bool{
"SpreadEven": false,
"MinContextSwitch": false,
},
}
stageLogs := make([]string, 0, 8)
nextState, err := runCompositeRouteNode(context.Background(), st, func(stage, detail string) {
stageLogs = append(stageLogs, stage+"|"+detail)
})
if err != nil {
t.Fatalf("runCompositeRouteNode 返回错误: %v", err)
}
if nextState == nil {
t.Fatalf("runCompositeRouteNode 返回 nil state")
}
if !nextState.CompositeRouteSucceeded {
t.Fatalf("期望复合分支在缺少 deterministic objective 时直接出站,实际 CompositeRouteSucceeded=false, stages=%v, action_logs=%v", stageLogs, nextState.ActionLogs)
}
if nextState.DisableCompositeTools {
t.Fatalf("期望复合分支直接进入终审,不应降级为禁复合 ReAct")
}
if !nextState.CompositeToolSuccess["MinContextSwitch"] {
t.Fatalf("期望 MinContextSwitch 成功状态被记录")
}
}

View File

@@ -0,0 +1,179 @@
package schedulerefine
import (
"fmt"
"sort"
"testing"
"github.com/LoveLosita/smartflow/backend/model"
)
func TestRefineToolSpreadEvenSuccess(t *testing.T) {
entries := []model.HybridScheduleEntry{
{TaskItemID: 1, Name: "任务1", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, ContextTag: "A"},
{TaskItemID: 2, Name: "任务2", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, ContextTag: "B"},
{TaskItemID: 99, Name: "课程", Type: "course", Status: "existing", Week: 12, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, BlockForSuggested: true},
}
params := map[string]any{
"task_item_ids": []any{1.0, 2.0},
"week": 12,
"day_of_week": []any{1.0, 2.0, 3.0},
"allow_embed": false,
}
policy := refineToolPolicy{OriginOrderMap: map[int]int{1: 1, 2: 2}}
nextEntries, result := refineToolSpreadEven(entries, params, planningWindow{Enabled: false}, policy)
if !result.Success {
t.Fatalf("SpreadEven 执行失败: %s", result.Result)
}
if result.Tool != "SpreadEven" {
t.Fatalf("工具名错误,期望 SpreadEven实际=%s", result.Tool)
}
idx1 := findSuggestedByID(nextEntries, 1)
idx2 := findSuggestedByID(nextEntries, 2)
if idx1 < 0 || idx2 < 0 {
t.Fatalf("移动后未找到目标任务: idx1=%d idx2=%d", idx1, idx2)
}
task1 := nextEntries[idx1]
task2 := nextEntries[idx2]
if task1.Week != 12 || task2.Week != 12 {
t.Fatalf("期望任务被移动到 W12实际 task1=%d task2=%d", task1.Week, task2.Week)
}
if task1.DayOfWeek < 1 || task1.DayOfWeek > 3 || task2.DayOfWeek < 1 || task2.DayOfWeek > 3 {
t.Fatalf("期望任务被移动到周一到周三,实际 task1=%d task2=%d", task1.DayOfWeek, task2.DayOfWeek)
}
if task1.DayOfWeek == task2.DayOfWeek && sectionsOverlap(task1.SectionFrom, task1.SectionTo, task2.SectionFrom, task2.SectionTo) {
t.Fatalf("复合工具不应产出重叠坑位: task1=%+v task2=%+v", task1, task2)
}
}
func TestRefineToolMinContextSwitchGroupsContext(t *testing.T) {
entries := []model.HybridScheduleEntry{
{TaskItemID: 11, Name: "任务11", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, ContextTag: "数学"},
{TaskItemID: 12, Name: "任务12", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, ContextTag: "算法"},
{TaskItemID: 13, Name: "任务13", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, ContextTag: "数学"},
{TaskItemID: 99, Name: "课程", Type: "course", Status: "existing", Week: 12, DayOfWeek: 1, SectionFrom: 11, SectionTo: 12, BlockForSuggested: true},
}
params := map[string]any{
"task_item_ids": []any{11.0, 12.0, 13.0},
"week": 12,
"day_of_week": []any{1.0},
}
policy := refineToolPolicy{OriginOrderMap: map[int]int{11: 1, 12: 2, 13: 3}}
nextEntries, result := refineToolMinContextSwitch(entries, params, planningWindow{Enabled: false}, policy)
if !result.Success {
t.Fatalf("MinContextSwitch 执行失败: %s", result.Result)
}
if result.Tool != "MinContextSwitch" {
t.Fatalf("工具名错误,期望 MinContextSwitch实际=%s", result.Tool)
}
selected := make([]model.HybridScheduleEntry, 0, 3)
for _, id := range []int{11, 12, 13} {
idx := findSuggestedByID(nextEntries, id)
if idx < 0 {
t.Fatalf("未找到任务 id=%d", id)
}
selected = append(selected, nextEntries[idx])
}
sort.SliceStable(selected, func(i, j int) bool {
if selected[i].Week != selected[j].Week {
return selected[i].Week < selected[j].Week
}
if selected[i].DayOfWeek != selected[j].DayOfWeek {
return selected[i].DayOfWeek < selected[j].DayOfWeek
}
return selected[i].SectionFrom < selected[j].SectionFrom
})
switches := 0
for i := 1; i < len(selected); i++ {
if selected[i].ContextTag != selected[i-1].ContextTag {
switches++
}
}
if switches > 1 {
t.Fatalf("期望最少上下文切换(<=1实际 switches=%d, tasks=%+v", switches, selected)
}
if selected[0].TaskItemID != 11 || selected[1].TaskItemID != 13 || selected[2].TaskItemID != 12 {
t.Fatalf("期望在原坑位集合内重排为 11,13,12实际=%+v", selected)
}
for _, task := range selected {
if task.Week != 16 || task.DayOfWeek != 1 {
t.Fatalf("MinContextSwitch 不应跳出原坑位集合,实际 task=%+v", task)
}
}
}
func TestRefineToolMinContextSwitchKeepsCurrentSlotSet(t *testing.T) {
entries := []model.HybridScheduleEntry{
{TaskItemID: 21, Name: "随机事件与概率基础概念复习", Type: "task", Status: "suggested", Week: 14, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, ContextTag: "General"},
{TaskItemID: 22, Name: "数制、码制与逻辑代数基础", Type: "task", Status: "suggested", Week: 14, DayOfWeek: 1, SectionFrom: 11, SectionTo: 12, ContextTag: "General"},
{TaskItemID: 23, Name: "第二章 条件概率与全概率公式", Type: "task", Status: "suggested", Week: 14, DayOfWeek: 3, SectionFrom: 3, SectionTo: 4, ContextTag: "General"},
}
params := map[string]any{
"task_item_ids": []any{21.0, 22.0, 23.0},
"week": 14,
"limit": 48,
"allow_embed": true,
}
policy := refineToolPolicy{OriginOrderMap: map[int]int{21: 1, 22: 2, 23: 3}}
nextEntries, result := refineToolMinContextSwitch(entries, params, planningWindow{Enabled: false}, policy)
if !result.Success {
t.Fatalf("MinContextSwitch 执行失败: %s", result.Result)
}
selected := make([]model.HybridScheduleEntry, 0, 3)
for _, id := range []int{21, 22, 23} {
idx := findSuggestedByID(nextEntries, id)
if idx < 0 {
t.Fatalf("未找到任务 id=%d", id)
}
selected = append(selected, nextEntries[idx])
}
sort.SliceStable(selected, func(i, j int) bool {
if selected[i].Week != selected[j].Week {
return selected[i].Week < selected[j].Week
}
if selected[i].DayOfWeek != selected[j].DayOfWeek {
return selected[i].DayOfWeek < selected[j].DayOfWeek
}
return selected[i].SectionFrom < selected[j].SectionFrom
})
if selected[0].TaskItemID != 21 || selected[1].TaskItemID != 23 || selected[2].TaskItemID != 22 {
t.Fatalf("期望按原坑位集合重排为概率, 概率, 数电,实际=%+v", selected)
}
expectedSlots := map[int]string{
21: "14-1-1-2",
23: "14-1-11-12",
22: "14-3-3-4",
}
for _, task := range selected {
got := fmt.Sprintf("%d-%d-%d-%d", task.Week, task.DayOfWeek, task.SectionFrom, task.SectionTo)
if got != expectedSlots[task.TaskItemID] {
t.Fatalf("任务 id=%d 应仅在原坑位集合内换位,期望=%s 实际=%s", task.TaskItemID, expectedSlots[task.TaskItemID], got)
}
}
}
func TestListTaskIDsFromToolCallComposite(t *testing.T) {
call := reactToolCall{
Tool: "SpreadEven",
Params: map[string]any{
"task_item_ids": []any{1.0, 2.0, 2.0},
"task_item_id": 3,
},
}
ids := listTaskIDsFromToolCall(call)
if len(ids) != 3 {
t.Fatalf("期望提取 3 个去重 ID实际=%v", ids)
}
sort.Ints(ids)
if ids[0] != 1 || ids[1] != 2 || ids[2] != 3 {
t.Fatalf("提取结果错误,实际=%v", ids)
}
}

View File

@@ -0,0 +1,114 @@
package schedulerefine
import (
"context"
"fmt"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/compose"
)
const (
graphNodeContract = "schedule_refine_contract"
graphNodePlan = "schedule_refine_plan"
graphNodeSlice = "schedule_refine_slice"
graphNodeRoute = "schedule_refine_route"
graphNodeReact = "schedule_refine_react"
graphNodeHardCheck = "schedule_refine_hard_check"
graphNodeSummary = "schedule_refine_summary"
)
// ScheduleRefineGraphRunInput 是“连续微调图”运行参数。
//
// 字段语义:
// 1. Model本轮图运行使用的聊天模型。
// 2. State预先注入的微调状态通常来自上一版预览快照
// 3. EmitStageSSE 阶段回调,允许服务层把阶段进度透传给前端。
type ScheduleRefineGraphRunInput struct {
Model *ark.ChatModel
State *ScheduleRefineState
EmitStage func(stage, detail string)
}
// RunScheduleRefineGraph 执行“连续微调”独立图链路。
//
// 链路顺序:
// START -> contract -> plan -> slice -> route -> react -> hard_check -> summary -> END
//
// 设计说明:
// 1. 当前链路采用线性图,确保可读性优先;
// 2. “终审失败后单次修复”在 hard_check 节点内部闭环处理,避免图连线分叉过多;
// 3. 若后续需要引入多分支策略(例如大改动转重排),可在 contract 后追加 branch 节点。
func RunScheduleRefineGraph(ctx context.Context, input ScheduleRefineGraphRunInput) (*ScheduleRefineState, error) {
if input.Model == nil {
return nil, fmt.Errorf("schedule refine graph: model is nil")
}
if input.State == nil {
return nil, fmt.Errorf("schedule refine graph: state is nil")
}
emitStage := func(stage, detail string) {
if input.EmitStage != nil {
input.EmitStage(stage, detail)
}
}
runner := newScheduleRefineRunner(input.Model, emitStage)
graph := compose.NewGraph[*ScheduleRefineState, *ScheduleRefineState]()
if err := graph.AddLambdaNode(graphNodeContract, compose.InvokableLambda(runner.contractNode)); err != nil {
return nil, err
}
if err := graph.AddLambdaNode(graphNodePlan, compose.InvokableLambda(runner.planNode)); err != nil {
return nil, err
}
if err := graph.AddLambdaNode(graphNodeSlice, compose.InvokableLambda(runner.sliceNode)); err != nil {
return nil, err
}
if err := graph.AddLambdaNode(graphNodeRoute, compose.InvokableLambda(runner.routeNode)); err != nil {
return nil, err
}
if err := graph.AddLambdaNode(graphNodeReact, compose.InvokableLambda(runner.reactNode)); err != nil {
return nil, err
}
if err := graph.AddLambdaNode(graphNodeHardCheck, compose.InvokableLambda(runner.hardCheckNode)); err != nil {
return nil, err
}
if err := graph.AddLambdaNode(graphNodeSummary, compose.InvokableLambda(runner.summaryNode)); err != nil {
return nil, err
}
if err := graph.AddEdge(compose.START, graphNodeContract); err != nil {
return nil, err
}
if err := graph.AddEdge(graphNodeContract, graphNodePlan); err != nil {
return nil, err
}
if err := graph.AddEdge(graphNodePlan, graphNodeSlice); err != nil {
return nil, err
}
if err := graph.AddEdge(graphNodeSlice, graphNodeRoute); err != nil {
return nil, err
}
if err := graph.AddEdge(graphNodeRoute, graphNodeReact); err != nil {
return nil, err
}
if err := graph.AddEdge(graphNodeReact, graphNodeHardCheck); err != nil {
return nil, err
}
if err := graph.AddEdge(graphNodeHardCheck, graphNodeSummary); err != nil {
return nil, err
}
if err := graph.AddEdge(graphNodeSummary, compose.END); err != nil {
return nil, err
}
runnable, err := graph.Compile(ctx,
compose.WithGraphName("ScheduleRefineGraph"),
compose.WithMaxRunSteps(20),
compose.WithNodeTriggerMode(compose.AnyPredecessor),
)
if err != nil {
return nil, err
}
return runnable.Invoke(ctx, input.State)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,188 @@
package schedulerefine
const (
// contractPrompt 负责把用户自然语言微调请求抽取为结构化契约。
contractPrompt = `你是 SmartFlow 的排程微调契约分析器。
你会收到:当前时间、用户请求、已有排程摘要。
请只输出 JSON不要 Markdown不要解释不要代码块
{
"intent": "一句话概括本轮微调目标",
"strategy": "local_adjust|keep",
"hard_requirements": ["必须满足的硬性要求1","必须满足的硬性要求2"],
"hard_assertions": [
{
"metric": "source_move_ratio_percent|all_source_tasks_in_target_scope|source_remaining_count",
"operator": "==|<=|>=|between",
"value": 50,
"min": 50,
"max": 50,
"week": 17,
"target_week": 16
}
],
"keep_relative_order": true,
"order_scope": "global|week"
}
规则:
1. 除非用户明确表达“允许打乱顺序/顺序无所谓”keep_relative_order 默认 true。
2. 仅当用户明确放宽顺序时keep_relative_order 才允许为 falseorder_scope 默认 "global"。
3. 只要涉及移动任务strategy 必须是 local_adjust仅在无需改动时才用 keep。
4. hard_requirements 必须可验证,避免空泛描述。
5. hard_assertions 必须尽量结构化,避免只给自然语言目标。`
// plannerPrompt 只负责生成“执行路径”,不直接执行动作。
plannerPrompt = `你是 SmartFlow 的排程微调 Planner。
你会收到:用户请求、契约、最近动作观察。
请只输出 JSON不要 Markdown不要解释不要代码块
{
"summary": "本阶段执行策略一句话",
"steps": ["步骤1","步骤2","步骤3"]
}
规则:
1. steps 保持 3~4 条,优先“先取证再动作”。
2. summary <= 36 字,单步 <= 28 字。
3. 若目标是“均匀分散”steps 必须体现 SpreadEven 且包含“成功后才收口”的硬条件。
4. 若目标是“上下文切换最少/同科目连续”steps 必须体现 MinContextSwitch 且包含“成功后才收口”的硬条件。
5. 不要输出半截 JSON。`
// reactPrompt 用于“单任务微步 ReAct”执行器。
reactPrompt = `你是 SmartFlow 的单任务微步 ReAct 执行器。
当前只处理一个任务CURRENT_TASK不能发散到其它任务的主动改动。
你每轮只能做两件事之一:
1) 调用一个工具(基础工具或复合工具)
2) 输出 done=true 结束当前任务
工具分组:
- 基础工具QueryTargetTasks / QueryAvailableSlots / Move / Swap / BatchMove / Verify
- 复合工具SpreadEven / MinContextSwitch
工具说明(按职责):
1. QueryTargetTasks查询候选任务集合只读
常用参数week/week_filter/day_of_week/task_item_ids/status。
适用:先摸清“有哪些任务可动、当前在哪”。
2. QueryAvailableSlots查询可放置坑位只读默认先纯空位必要时补可嵌入位
常用参数week/week_filter/day_of_week/span/limit/allow_embed/exclude_sections。
适用Move 前先拿可落点清单。
3. Move移动单个任务到目标坑位写操作
必要参数task_item_id,to_week,to_day,to_section_from,to_section_to。
适用:单任务精确挪动。
4. Swap交换两个任务坑位写操作
必要参数task_a,task_b。
适用:两个任务互换位置比单独 Move 更稳时。
5. BatchMove批量原子移动写操作
必要参数:{"moves":[{Move参数...},{Move参数...}]}。
适用:一轮要改多个任务且要求“要么全成要么全回滚”。
6. Verify执行确定性校验只读
常用参数:可空;也可传 task_item_id + 目标坐标做定点核验。
适用:收尾前快速自检是否符合确定性约束。
7. SpreadEven复合按“均匀铺开”目标一次规划并执行多任务移动写操作
必要参数task_item_ids必须包含 CURRENT_TASK.task_item_id
可选参数week/week_filter/day_of_week/allow_embed/limit。
适用:目标是“把任务在时间上分散开,避免扎堆”。
8. MinContextSwitch复合按“最少上下文切换”一次规划并执行多任务移动写操作
必要参数task_item_ids必须包含 CURRENT_TASK.task_item_id
可选参数week/week_filter/day_of_week/allow_embed/limit。
适用:目标是“同科目/同认知标签尽量连续,减少切换成本”。
请严格输出 JSON不要 Markdown不要解释
{
"done": false,
"summary": "",
"goal_check": "本轮先检查什么",
"decision": "本轮为何这么做",
"missing_info": ["缺口信息1","缺口信息2"],
"tool_calls": [
{
"tool": "QueryTargetTasks|QueryAvailableSlots|Move|Swap|BatchMove|SpreadEven|MinContextSwitch|Verify",
"params": {}
}
]
}
硬规则:
1. 每轮最多 1 个 tool_call。
2. done=true 时tool_calls 必须为空数组。
3. done=false 时tool_calls 必须恰好 1 条。
4. 只能修改 status="suggested" 的任务,禁止修改 existing。
5. 不要把“顺序约束”当作执行期阻塞条件;你只需把坑位分布排好,顺序由后端统一收口。
6. 若上轮失败,必须依据 LAST_TOOL_OBSERVATION.error_code 调整策略,不能重复上轮失败动作。
7. Move 参数优先使用task_item_id,to_week,to_day,to_section_from,to_section_to。
8. BatchMove 参数格式必须是:{"moves":[{...},{...}]};任一步失败会整批回滚。
9. day_of_week 映射固定1周一,2周二,3周三,4周四,5周五,6周六,7周日。
10. 优先使用“纯空位”;仅在空位不足时再考虑可嵌入课程位(第二优先级)。
11. 如果 SOURCE_WEEK_FILTER 非空,只允许改写这些来源周里的任务,禁止主动改写其它周任务。
12. CURRENT_TASK 是本轮唯一可改写任务;如果它已满足目标,立刻 done=true不要提前处理下一个任务。
13. 禁止发明工具名(如 GetCurrentTask、AdjustTaskTime只能用白名单工具。
14. 优先使用后端注入的 ENV_SLOT_HINT 进行落点决策,非必要不要重复 QueryAvailableSlots。
15. 若 REQUIRED_COMPOSITE_TOOL 非空且 COMPOSITE_REQUIRED_SUCCESS=false本轮必须优先调用 REQUIRED_COMPOSITE_TOOL禁止先调用 Move/Swap/BatchMove。
16. 若使用 SpreadEven/MinContextSwitch必须在参数中提供 task_item_ids且包含 CURRENT_TASK.task_item_id
17. 若 COMPOSITE_TOOLS_ALLOWED=false禁止调用 SpreadEven/MinContextSwitch只能使用基础工具逐步处理。
18. 为保证解析稳定goal_check<=50字decision<=90字summary<=60字。`
// postReflectPrompt 要求模型基于真实工具结果做复盘,不允许“脑补成功”。
postReflectPrompt = `你是 SmartFlow 的 ReAct 复盘器。
你会收到:本轮工具参数、后端真实执行结果、上一轮上下文。
请只输出 JSON不要 Markdown不要解释
{
"reflection": "基于真实结果的复盘",
"next_strategy": "下一轮建议动作",
"should_stop": false
}
规则:
1. 若 tool_success=falsereflection 必须明确失败原因(优先引用 error_code
2. 若 error_code 属于 ORDER_VIOLATION/SLOT_CONFLICT/REPEAT_FAILED_ACTIONnext_strategy 必须给出规避方法。
3. should_stop=true 仅用于“目标已满足”或“继续收益很低”。`
// reviewPrompt 用于终审语义校验。
reviewPrompt = `你是 SmartFlow 的终审校验器。
请判断“当前排程”是否满足“本轮用户微调请求 + 契约硬要求”。
只输出 JSON
{
"pass": true,
"reason": "中文简短结论",
"unmet": []
}
规则:
1. pass=true 时 unmet 必须为空数组。
2. pass=false 时 reason 必须给出核心差距。`
// summaryPrompt 用于最终面向用户的自然语言总结。
summaryPrompt = `你是 SmartFlow 的排程结果解读助手。
请基于输入输出 2~4 句中文总结:
1) 先说明本轮改了什么;
2) 再说明改动收益;
3) 若终审未完全通过,明确还差什么。
不要输出 JSON。`
// repairPrompt 用于终审失败后的单次修复动作。
repairPrompt = `你是 SmartFlow 的修复执行器。
当前方案未通过终审,请根据“未满足点”只做一次修复动作。
只允许输出一个 tool_callMove 或 Swap不允许 done。
输出格式(严格 JSON
{
"done": false,
"summary": "",
"goal_check": "本轮修复目标",
"decision": "修复决策依据",
"missing_info": [],
"tool_calls": [
{
"tool": "Move|Swap",
"params": {}
}
]
}
Move 参数必须使用标准键:
- task_item_id
- to_week
- to_day
- to_section_from
- to_section_to
禁止使用 new_week/new_day/section_from 等别名。`
)

View File

@@ -0,0 +1,637 @@
package schedulerefine
import (
"encoding/json"
"strings"
"testing"
"github.com/LoveLosita/smartflow/backend/model"
)
func TestQueryTargetTasksWeekFilterAndTaskID(t *testing.T) {
entries := []model.HybridScheduleEntry{
{TaskItemID: 1, Name: "task-w12", Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, Status: "suggested", Type: "task"},
{TaskItemID: 2, Name: "task-w13", Week: 13, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, Status: "suggested", Type: "task"},
{TaskItemID: 3, Name: "task-w14", Week: 14, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, Status: "suggested", Type: "task"},
}
policy := refineToolPolicy{OriginOrderMap: map[int]int{1: 1, 2: 2, 3: 3}}
paramsWeek := map[string]any{
"week_filter": []any{13.0, 14.0},
}
_, resultWeek := refineToolQueryTargetTasks(entries, paramsWeek, policy)
if !resultWeek.Success {
t.Fatalf("week_filter 查询失败: %s", resultWeek.Result)
}
var payloadWeek struct {
Count int `json:"count"`
Items []struct {
TaskItemID int `json:"task_item_id"`
Week int `json:"week"`
} `json:"items"`
}
if err := json.Unmarshal([]byte(resultWeek.Result), &payloadWeek); err != nil {
t.Fatalf("解析 week_filter 结果失败: %v", err)
}
if payloadWeek.Count != 2 {
t.Fatalf("week_filter 期望返回 2 条,实际=%d", payloadWeek.Count)
}
for _, item := range payloadWeek.Items {
if item.Week != 13 && item.Week != 14 {
t.Fatalf("week_filter 过滤失败,出现非法周次=%d", item.Week)
}
}
paramsTaskID := map[string]any{
"week_filter": []any{13.0, 14.0},
"task_item_id": 2,
}
_, resultTaskID := refineToolQueryTargetTasks(entries, paramsTaskID, policy)
if !resultTaskID.Success {
t.Fatalf("task_item_id 查询失败: %s", resultTaskID.Result)
}
var payloadTaskID struct {
Count int `json:"count"`
Items []struct {
TaskItemID int `json:"task_item_id"`
Week int `json:"week"`
} `json:"items"`
}
if err := json.Unmarshal([]byte(resultTaskID.Result), &payloadTaskID); err != nil {
t.Fatalf("解析 task_item_id 结果失败: %v", err)
}
if payloadTaskID.Count != 1 {
t.Fatalf("task_item_id 期望返回 1 条,实际=%d", payloadTaskID.Count)
}
if payloadTaskID.Items[0].TaskItemID != 2 || payloadTaskID.Items[0].Week != 13 {
t.Fatalf("task_item_id 过滤错误: %+v", payloadTaskID.Items[0])
}
}
func TestQueryAvailableSlotsExactSectionAlias(t *testing.T) {
params := map[string]any{
"week": 13,
"section_duration": 2,
"section_from": 1,
"section_to": 2,
"limit": 5,
}
_, result := refineToolQueryAvailableSlots(nil, params, planningWindow{Enabled: false})
if !result.Success {
t.Fatalf("QueryAvailableSlots 失败: %s", result.Result)
}
var payload struct {
Count int `json:"count"`
Slots []struct {
Week int `json:"week"`
SectionFrom int `json:"section_from"`
SectionTo int `json:"section_to"`
} `json:"slots"`
}
if err := json.Unmarshal([]byte(result.Result), &payload); err != nil {
t.Fatalf("解析 QueryAvailableSlots 结果失败: %v", err)
}
if payload.Count == 0 {
t.Fatalf("期望至少返回一个可用时段,实际=0")
}
for _, slot := range payload.Slots {
if slot.Week != 13 {
t.Fatalf("返回了错误周次: %+v", slot)
}
if slot.SectionFrom != 1 || slot.SectionTo != 2 {
t.Fatalf("精确节次过滤失败: %+v", slot)
}
}
}
func TestQueryAvailableSlotsWeekFilterDayFilterAlias(t *testing.T) {
entries := []model.HybridScheduleEntry{
{TaskItemID: 1, Name: "task-w12", Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, Status: "suggested", Type: "task"},
{TaskItemID: 2, Name: "task-w17", Week: 17, DayOfWeek: 4, SectionFrom: 3, SectionTo: 4, Status: "suggested", Type: "task"},
}
params := map[string]any{
"week_filter": []any{17.0},
"day_filter": []any{1.0, 2.0, 3.0},
"limit": 20,
}
_, result := refineToolQueryAvailableSlots(entries, params, planningWindow{Enabled: false})
if !result.Success {
t.Fatalf("QueryAvailableSlots 别名查询失败: %s", result.Result)
}
var payload struct {
Count int `json:"count"`
Slots []struct {
Week int `json:"week"`
DayOfWeek int `json:"day_of_week"`
} `json:"slots"`
}
if err := json.Unmarshal([]byte(result.Result), &payload); err != nil {
t.Fatalf("解析 week/day 过滤结果失败: %v", err)
}
if payload.Count == 0 {
t.Fatalf("week_filter/day_filter 查询应返回 W17 周一到周三空位,实际为空")
}
for _, slot := range payload.Slots {
if slot.Week != 17 {
t.Fatalf("week_filter 失效,出现 week=%d", slot.Week)
}
if slot.DayOfWeek < 1 || slot.DayOfWeek > 3 {
t.Fatalf("day_filter 失效,出现 day_of_week=%d", slot.DayOfWeek)
}
}
}
func TestCollectWorksetTaskIDsSourceWeekOnly(t *testing.T) {
entries := []model.HybridScheduleEntry{
{TaskItemID: 1, Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, Status: "suggested", Type: "task"},
{TaskItemID: 2, Week: 14, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, Status: "suggested", Type: "task"},
{TaskItemID: 3, Week: 13, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, Status: "suggested", Type: "task"},
{TaskItemID: 4, Week: 14, DayOfWeek: 2, SectionFrom: 7, SectionTo: 8, Status: "suggested", Type: "task"},
}
slice := RefineSlicePlan{WeekFilter: []int{14, 13}}
originOrder := map[int]int{1: 1, 2: 2, 3: 3, 4: 4}
got := collectWorksetTaskIDs(entries, slice, originOrder)
if len(got) != 2 {
t.Fatalf("来源周收敛失败,期望 2 条,实际=%d, got=%v", len(got), got)
}
if got[0] != 2 || got[1] != 4 {
t.Fatalf("来源周结果错误,期望 [2 4],实际=%v", got)
}
}
func TestBuildSlicePlanDirectionalSourceTarget(t *testing.T) {
st := &ScheduleRefineState{
UserMessage: "帮我把第17周周四到周五的任务都收敛到17周的周一到周三优先放空位空位不够了再嵌入",
}
plan := buildSlicePlan(st)
if len(plan.WeekFilter) == 0 || plan.WeekFilter[0] != 17 {
t.Fatalf("week_filter 解析错误: %+v", plan.WeekFilter)
}
expectSource := []int{4, 5}
expectTarget := []int{1, 2, 3}
if len(plan.SourceDays) != len(expectSource) {
t.Fatalf("source_days 长度错误: got=%v", plan.SourceDays)
}
for i := range expectSource {
if plan.SourceDays[i] != expectSource[i] {
t.Fatalf("source_days 错误: got=%v", plan.SourceDays)
}
}
if len(plan.TargetDays) != len(expectTarget) {
t.Fatalf("target_days 长度错误: got=%v", plan.TargetDays)
}
for i := range expectTarget {
if plan.TargetDays[i] != expectTarget[i] {
t.Fatalf("target_days 错误: got=%v", plan.TargetDays)
}
}
}
func TestVerifyTaskCoordinateMismatch(t *testing.T) {
entries := []model.HybridScheduleEntry{
{TaskItemID: 28, Name: "task-w17-d4", Week: 17, DayOfWeek: 4, SectionFrom: 5, SectionTo: 6, Status: "suggested", Type: "task"},
}
policy := refineToolPolicy{OriginOrderMap: map[int]int{28: 1}}
params := map[string]any{
"task_item_id": 28,
"week": 17,
"day_of_week": 1,
"section_from": 1,
"section_to": 2,
}
_, result := refineToolVerify(entries, params, policy)
if result.Success {
t.Fatalf("期望 Verify 在任务坐标不匹配时失败,实际 success=true, result=%s", result.Result)
}
if result.ErrorCode != "VERIFY_FAILED" {
t.Fatalf("期望错误码 VERIFY_FAILED实际=%s", result.ErrorCode)
}
if !strings.Contains(result.Result, "不匹配") {
t.Fatalf("期望结果包含“不匹配”提示,实际=%s", result.Result)
}
}
func TestMoveRejectsSuggestedCourseEntry(t *testing.T) {
entries := []model.HybridScheduleEntry{
{
TaskItemID: 39,
Name: "面向对象程序设计-C++",
Type: "course",
Status: "suggested",
Week: 17,
DayOfWeek: 4,
SectionFrom: 7,
SectionTo: 8,
},
}
params := map[string]any{
"task_item_id": 39,
"to_week": 17,
"to_day": 1,
"to_section_from": 7,
"to_section_to": 8,
}
_, result := refineToolMove(entries, params, planningWindow{Enabled: false}, refineToolPolicy{OriginOrderMap: map[int]int{39: 1}})
if result.Success {
t.Fatalf("期望 course 类型的 suggested 条目不可移动,实际 success=true, result=%s", result.Result)
}
if !strings.Contains(result.Result, "可移动 suggested 任务") {
t.Fatalf("期望返回不可移动提示,实际=%s", result.Result)
}
}
func TestQueryAvailableSlotsSlotTypePureDisablesEmbed(t *testing.T) {
entries := []model.HybridScheduleEntry{
{
Name: "可嵌入课程",
Type: "course",
Status: "existing",
Week: 17,
DayOfWeek: 1,
SectionFrom: 1,
SectionTo: 2,
BlockForSuggested: false,
},
}
pureParams := map[string]any{
"week": 17,
"day_of_week": 1,
"section_from": 1,
"section_to": 2,
"slot_type": "pure",
}
_, pureResult := refineToolQueryAvailableSlots(entries, pureParams, planningWindow{Enabled: false})
if !pureResult.Success {
t.Fatalf("pure 查询失败: %s", pureResult.Result)
}
var purePayload struct {
Count int `json:"count"`
EmbeddedCount int `json:"embedded_count"`
FallbackUsed bool `json:"fallback_used"`
}
if err := json.Unmarshal([]byte(pureResult.Result), &purePayload); err != nil {
t.Fatalf("解析 pure 查询结果失败: %v", err)
}
if purePayload.Count != 0 || purePayload.EmbeddedCount != 0 || purePayload.FallbackUsed {
t.Fatalf("slot_type=pure 应禁用嵌入兜底,实际 payload=%+v", purePayload)
}
defaultParams := map[string]any{
"week": 17,
"day_of_week": 1,
"section_from": 1,
"section_to": 2,
}
_, defaultResult := refineToolQueryAvailableSlots(entries, defaultParams, planningWindow{Enabled: false})
if !defaultResult.Success {
t.Fatalf("default 查询失败: %s", defaultResult.Result)
}
var defaultPayload struct {
Count int `json:"count"`
EmbeddedCount int `json:"embedded_count"`
FallbackUsed bool `json:"fallback_used"`
}
if err := json.Unmarshal([]byte(defaultResult.Result), &defaultPayload); err != nil {
t.Fatalf("解析 default 查询结果失败: %v", err)
}
if defaultPayload.Count == 0 || defaultPayload.EmbeddedCount == 0 || !defaultPayload.FallbackUsed {
t.Fatalf("默认查询应允许嵌入候选,实际 payload=%+v", defaultPayload)
}
}
func TestCompileObjectiveAndEvaluateMoveAllPass(t *testing.T) {
initial := []model.HybridScheduleEntry{
{TaskItemID: 39, Name: "任务39", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 4, SectionFrom: 7, SectionTo: 8},
{TaskItemID: 51, Name: "任务51", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 5, SectionFrom: 9, SectionTo: 10},
}
final := []model.HybridScheduleEntry{
{TaskItemID: 39, Name: "任务39", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 1, SectionFrom: 7, SectionTo: 8},
{TaskItemID: 51, Name: "任务51", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 2, SectionFrom: 9, SectionTo: 10},
}
st := &ScheduleRefineState{
UserMessage: "把17周周四到周五任务收敛到周一到周三",
InitialHybridEntries: initial,
HybridEntries: final,
SlicePlan: RefineSlicePlan{
WeekFilter: []int{17},
SourceDays: []int{4, 5},
TargetDays: []int{1, 2, 3},
},
}
st.Objective = compileRefineObjective(st, st.SlicePlan)
if st.Objective.Mode != "move_all" {
t.Fatalf("期望目标模式 move_all实际=%s", st.Objective.Mode)
}
pass, _, unmet, applied := evaluateObjectiveDeterministic(st)
if !applied {
t.Fatalf("期望命中确定性终审")
}
if !pass {
t.Fatalf("期望确定性终审通过unmet=%v", unmet)
}
}
func TestCompileObjectiveAndEvaluateMoveAllFail(t *testing.T) {
initial := []model.HybridScheduleEntry{
{TaskItemID: 26, Name: "任务26", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 5, SectionFrom: 7, SectionTo: 8},
}
final := []model.HybridScheduleEntry{
{TaskItemID: 26, Name: "任务26", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 5, SectionFrom: 7, SectionTo: 8},
}
st := &ScheduleRefineState{
UserMessage: "把17周周四到周五任务收敛到周一到周三",
InitialHybridEntries: initial,
HybridEntries: final,
SlicePlan: RefineSlicePlan{
WeekFilter: []int{17},
SourceDays: []int{4, 5},
TargetDays: []int{1, 2, 3},
},
}
st.Objective = compileRefineObjective(st, st.SlicePlan)
pass, _, unmet, applied := evaluateObjectiveDeterministic(st)
if !applied {
t.Fatalf("期望命中确定性终审")
}
if pass {
t.Fatalf("期望确定性终审失败")
}
if len(unmet) == 0 {
t.Fatalf("期望返回未满足项")
}
}
func TestCompileObjectiveMoveRatioFromContractAndEvaluatePass(t *testing.T) {
initial, final := buildHalfTransferEntries(10, 5)
st := &ScheduleRefineState{
UserMessage: "17周任务太多帮我调整到16周",
InitialHybridEntries: initial,
HybridEntries: final,
SlicePlan: RefineSlicePlan{
WeekFilter: []int{17, 16},
},
Contract: RefineContract{
Intent: "将第17周任务匀一半到第16周",
HardRequirements: []string{"原第17周任务数调整为原来的一半", "调整到第16周的任务数为原第17周任务数的一半"},
},
}
st.Objective = compileRefineObjective(st, st.SlicePlan)
if st.Objective.Mode != "move_ratio" {
t.Fatalf("期望目标模式 move_ratio实际=%s", st.Objective.Mode)
}
if st.Objective.RequiredMoveMin != 5 || st.Objective.RequiredMoveMax != 5 {
t.Fatalf("半数迁移阈值错误: min=%d max=%d", st.Objective.RequiredMoveMin, st.Objective.RequiredMoveMax)
}
pass, _, unmet, applied := evaluateObjectiveDeterministic(st)
if !applied {
t.Fatalf("期望命中确定性终审")
}
if !pass {
t.Fatalf("期望半数迁移通过unmet=%v", unmet)
}
}
func TestCompileObjectiveMoveRatioFromContractAndEvaluateFail(t *testing.T) {
initial, final := buildHalfTransferEntries(10, 4)
st := &ScheduleRefineState{
UserMessage: "17周任务太多帮我调整到16周",
InitialHybridEntries: initial,
HybridEntries: final,
SlicePlan: RefineSlicePlan{
WeekFilter: []int{17, 16},
},
Contract: RefineContract{
Intent: "将第17周任务匀一半到第16周",
HardRequirements: []string{"原第17周任务数调整为原来的一半", "调整到第16周的任务数为原第17周任务数的一半"},
},
}
st.Objective = compileRefineObjective(st, st.SlicePlan)
pass, _, unmet, applied := evaluateObjectiveDeterministic(st)
if !applied {
t.Fatalf("期望命中确定性终审")
}
if pass {
t.Fatalf("期望半数迁移失败")
}
if len(unmet) == 0 {
t.Fatalf("期望返回未满足项")
}
}
func TestCompileObjectiveMoveRatioFromStructuredAssertion(t *testing.T) {
initial, final := buildHalfTransferEntries(10, 5)
st := &ScheduleRefineState{
UserMessage: "请把任务重新分配",
InitialHybridEntries: initial,
HybridEntries: final,
SlicePlan: RefineSlicePlan{
WeekFilter: []int{17, 16},
},
Contract: RefineContract{
Intent: "任务重新分配",
HardAssertions: []RefineAssertion{
{
Metric: "source_move_ratio_percent",
Operator: "==",
Value: 50,
Week: 17,
TargetWeek: 16,
},
},
},
}
st.Objective = compileRefineObjective(st, st.SlicePlan)
if st.Objective.Mode != "move_ratio" {
t.Fatalf("结构化断言未生效,期望 move_ratio实际=%s", st.Objective.Mode)
}
}
func buildHalfTransferEntries(total int, moved int) ([]model.HybridScheduleEntry, []model.HybridScheduleEntry) {
initial := make([]model.HybridScheduleEntry, 0, total)
final := make([]model.HybridScheduleEntry, 0, total)
for i := 1; i <= total; i++ {
initial = append(initial, model.HybridScheduleEntry{
TaskItemID: i,
Name: "task",
Type: "task",
Status: "suggested",
Week: 17,
DayOfWeek: 1,
SectionFrom: 1,
SectionTo: 2,
})
week := 17
if i <= moved {
week = 16
}
final = append(final, model.HybridScheduleEntry{
TaskItemID: i,
Name: "task",
Type: "task",
Status: "suggested",
Week: week,
DayOfWeek: 1,
SectionFrom: 1,
SectionTo: 2,
})
}
return initial, final
}
func TestNormalizeMovableTaskOrderByOrigin(t *testing.T) {
st := &ScheduleRefineState{
OriginOrderMap: map[int]int{
101: 1,
202: 2,
},
HybridEntries: []model.HybridScheduleEntry{
{TaskItemID: 202, Name: "task-202", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2},
{TaskItemID: 101, Name: "task-101", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 3, SectionFrom: 1, SectionTo: 2},
},
}
changed := normalizeMovableTaskOrderByOrigin(st)
if !changed {
t.Fatalf("期望发生顺序归位")
}
sortHybridEntries(st.HybridEntries)
if st.HybridEntries[0].TaskItemID != 101 || st.HybridEntries[1].TaskItemID != 202 {
t.Fatalf("顺序归位失败: %+v", st.HybridEntries)
}
}
func TestTryNormalizeMovableTaskOrderByOriginSkipsAfterMinContextSwitch(t *testing.T) {
st := &ScheduleRefineState{
OriginOrderMap: map[int]int{
101: 1,
202: 2,
},
CompositeToolSuccess: map[string]bool{
"SpreadEven": false,
"MinContextSwitch": true,
},
HybridEntries: []model.HybridScheduleEntry{
{TaskItemID: 202, Name: "task-202", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2},
{TaskItemID: 101, Name: "task-101", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 3, SectionFrom: 1, SectionTo: 2},
},
}
changed, skipped := tryNormalizeMovableTaskOrderByOrigin(st)
if !skipped {
t.Fatalf("期望 MinContextSwitch 成功后跳过顺序归位")
}
if changed {
t.Fatalf("跳过顺序归位时不应报告 changed=true")
}
if st.HybridEntries[0].TaskItemID != 202 || st.HybridEntries[1].TaskItemID != 101 {
t.Fatalf("跳过顺序归位后不应改写任务顺序: %+v", st.HybridEntries)
}
}
func TestEvaluateHardChecksSkipsOrderConstraintAfterMinContextSwitch(t *testing.T) {
st := &ScheduleRefineState{
UserMessage: "减少第15周科目切换",
OriginOrderMap: map[int]int{
101: 1,
202: 2,
},
CompositeToolSuccess: map[string]bool{
"SpreadEven": false,
"MinContextSwitch": true,
},
InitialHybridEntries: []model.HybridScheduleEntry{
{TaskItemID: 101, Name: "概率任务", Type: "task", Status: "suggested", Week: 15, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2},
{TaskItemID: 202, Name: "数电任务", Type: "task", Status: "suggested", Week: 15, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4},
},
HybridEntries: []model.HybridScheduleEntry{
{TaskItemID: 202, Name: "数电任务", Type: "task", Status: "suggested", Week: 15, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2},
{TaskItemID: 101, Name: "概率任务", Type: "task", Status: "suggested", Week: 15, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4},
},
Objective: RefineObjective{
Mode: "move_all",
SourceWeeks: []int{15},
TargetWeeks: []int{15},
BaselineSourceTaskCount: 2,
RequiredMoveMin: 2,
RequiredMoveMax: 2,
},
SlicePlan: RefineSlicePlan{
WeekFilter: []int{15},
},
}
report := evaluateHardChecks(nil, nil, st, nil)
if !report.OrderPassed {
t.Fatalf("期望 MinContextSwitch 成功后跳过顺序终审,实际 issues=%v", report.OrderIssues)
}
}
func TestPrecheckToolCallPolicyRejectsRedundantSlotQuery(t *testing.T) {
st := &ScheduleRefineState{
SeenSlotQueries: make(map[string]struct{}),
EntriesVersion: 0,
}
call := reactToolCall{
Tool: "QueryAvailableSlots",
Params: map[string]any{
"week": 16,
"day_of_week": 1,
},
}
if blockedResult, blocked := precheckToolCallPolicy(st, call, nil); blocked {
t.Fatalf("首次查询不应被拒绝: %+v", blockedResult)
}
if blockedResult, blocked := precheckToolCallPolicy(st, call, nil); !blocked {
t.Fatalf("重复查询应被拒绝")
} else if blockedResult.ErrorCode != "QUERY_REDUNDANT" {
t.Fatalf("错误码不符合预期: %+v", blockedResult)
}
st.EntriesVersion++
if blockedResult, blocked := precheckToolCallPolicy(st, call, nil); blocked {
t.Fatalf("排程版本变化后应允许再次查询: %+v", blockedResult)
}
}
func TestCanonicalizeMoveParamsFromRepairAliases(t *testing.T) {
call := reactToolCall{
Tool: "Move",
Params: map[string]any{
"task_item_id": 16,
"new_week": 16,
"day_of_week": 1,
"section_from": 1,
"section_to": 2,
},
}
normalized := canonicalizeToolCall(call)
if _, ok := paramIntAny(normalized.Params, "to_week"); !ok {
t.Fatalf("to_week 规范化失败: %+v", normalized.Params)
}
if _, ok := paramIntAny(normalized.Params, "to_day"); !ok {
t.Fatalf("to_day 规范化失败: %+v", normalized.Params)
}
if _, ok := paramIntAny(normalized.Params, "to_section_from"); !ok {
t.Fatalf("to_section_from 规范化失败: %+v", normalized.Params)
}
if _, ok := paramIntAny(normalized.Params, "to_section_to"); !ok {
t.Fatalf("to_section_to 规范化失败: %+v", normalized.Params)
}
}
func TestDetectOrderIntentDefaultsToKeep(t *testing.T) {
if !detectOrderIntent("16周总体任务太多了帮我移动一半到12周") {
t.Fatalf("未显式放宽顺序时,默认应保持顺序")
}
}
func TestDetectOrderIntentExplicitAllowReorder(t *testing.T) {
if detectOrderIntent("这次顺序无所谓,可以打乱顺序") {
t.Fatalf("用户明确允许乱序时,应关闭顺序约束")
}
}

View File

@@ -0,0 +1,53 @@
package schedulerefine
import (
"context"
"github.com/cloudwego/eino-ext/components/model/ark"
)
// scheduleRefineRunner 是“单次图运行”的请求级依赖容器。
//
// 职责边界:
// 1. 负责收口模型与阶段回调,避免 graph.go 出现大量闭包;
// 2. 负责把节点函数适配为统一签名;
// 3. 不负责分支决策(当前链路为线性图)。
type scheduleRefineRunner struct {
chatModel *ark.ChatModel
emitStage func(stage, detail string)
}
func newScheduleRefineRunner(chatModel *ark.ChatModel, emitStage func(stage, detail string)) *scheduleRefineRunner {
return &scheduleRefineRunner{
chatModel: chatModel,
emitStage: emitStage,
}
}
func (r *scheduleRefineRunner) contractNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) {
return runContractNode(ctx, r.chatModel, st, r.emitStage)
}
func (r *scheduleRefineRunner) planNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) {
return runPlanNode(ctx, r.chatModel, st, r.emitStage)
}
func (r *scheduleRefineRunner) sliceNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) {
return runSliceNode(ctx, st, r.emitStage)
}
func (r *scheduleRefineRunner) routeNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) {
return runCompositeRouteNode(ctx, st, r.emitStage)
}
func (r *scheduleRefineRunner) reactNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) {
return runReactLoopNode(ctx, r.chatModel, st, r.emitStage)
}
func (r *scheduleRefineRunner) hardCheckNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) {
return runHardCheckNode(ctx, r.chatModel, st, r.emitStage)
}
func (r *scheduleRefineRunner) summaryNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) {
return runSummaryNode(ctx, r.chatModel, st, r.emitStage)
}

View File

@@ -0,0 +1,377 @@
package schedulerefine
import (
"sort"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/model"
)
const (
// 固定业务时区,避免“今天/明天”在容器默认时区下偏移。
timezoneName = "Asia/Shanghai"
// 统一分钟级时间文本格式。
datetimeLayout = "2006-01-02 15:04"
// 预算默认值。
defaultPlanMax = 2
defaultExecuteMax = 24
defaultPerTaskBudget = 4
defaultReplanMax = 2
defaultCompositeRetry = 2
defaultRepairReserve = 1
)
// RefineContract 表示本轮微调意图契约。
type RefineContract struct {
Intent string `json:"intent"`
Strategy string `json:"strategy"`
HardRequirements []string `json:"hard_requirements"`
HardAssertions []RefineAssertion `json:"hard_assertions,omitempty"`
KeepRelativeOrder bool `json:"keep_relative_order"`
OrderScope string `json:"order_scope"`
}
// RefineAssertion 表示可由后端直接判定的结构化硬断言。
//
// 字段说明:
// 1. Metric断言指标名例如 source_move_ratio_percent
// 2. Operator比较操作符支持 == / <= / >= / between
// 3. Value/Min/Max阈值
// 4. Week/TargetWeek可选周次上下文。
type RefineAssertion struct {
Metric string `json:"metric"`
Operator string `json:"operator"`
Value int `json:"value,omitempty"`
Min int `json:"min,omitempty"`
Max int `json:"max,omitempty"`
Week int `json:"week,omitempty"`
TargetWeek int `json:"target_week,omitempty"`
}
// HardCheckReport 表示终审硬校验结果。
type HardCheckReport struct {
PhysicsPassed bool `json:"physics_passed"`
PhysicsIssues []string `json:"physics_issues,omitempty"`
IntentPassed bool `json:"intent_passed"`
IntentReason string `json:"intent_reason,omitempty"`
IntentUnmet []string `json:"intent_unmet,omitempty"`
OrderPassed bool `json:"order_passed"`
OrderIssues []string `json:"order_issues,omitempty"`
RepairTried bool `json:"repair_tried"`
}
// ReactRoundObservation 记录每轮 ReAct 的关键观察。
type ReactRoundObservation struct {
Round int `json:"round"`
GoalCheck string `json:"goal_check,omitempty"`
Decision string `json:"decision,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ToolParams map[string]any `json:"tool_params,omitempty"`
ToolSuccess bool `json:"tool_success"`
ToolErrorCode string `json:"tool_error_code,omitempty"`
ToolResult string `json:"tool_result,omitempty"`
Reflect string `json:"reflect,omitempty"`
}
// PlannerPlan 表示 Planner 生成的阶段执行计划。
type PlannerPlan struct {
Summary string `json:"summary"`
Steps []string `json:"steps,omitempty"`
}
// RefineSlicePlan 表示切片节点输出。
type RefineSlicePlan struct {
WeekFilter []int `json:"week_filter,omitempty"`
SourceDays []int `json:"source_days,omitempty"`
TargetDays []int `json:"target_days,omitempty"`
ExcludeSections []int `json:"exclude_sections,omitempty"`
Reason string `json:"reason,omitempty"`
}
// RefineObjective 表示“可执行且可校验”的目标约束。
//
// 设计说明:
// 1. 由 contract/slice 从自然语言编译得到;
// 2. 执行阶段done 收口与终审阶段hard_check共用同一份约束
// 3. 避免“执行逻辑与终审逻辑各说各话”。
type RefineObjective struct {
Mode string `json:"mode,omitempty"` // none | move_all | move_ratio
SourceWeeks []int `json:"source_weeks,omitempty"`
TargetWeeks []int `json:"target_weeks,omitempty"`
SourceDays []int `json:"source_days,omitempty"`
TargetDays []int `json:"target_days,omitempty"`
ExcludeSections []int `json:"exclude_sections,omitempty"`
BaselineSourceTaskCount int `json:"baseline_source_task_count,omitempty"`
RequiredMoveMin int `json:"required_move_min,omitempty"`
RequiredMoveMax int `json:"required_move_max,omitempty"`
Reason string `json:"reason,omitempty"`
}
// ScheduleRefineState 是连续微调图的统一状态。
type ScheduleRefineState struct {
// 1) 请求上下文
TraceID string
UserID int
ConversationID string
UserMessage string
RequestNow time.Time
RequestNowText string
// 2) 继承自预览快照的数据
TaskClassIDs []int
Constraints []string
// InitialHybridEntries 保存本轮微调开始前的基线,用于终审做“前后对比”。
// 说明:
// 1. 只读语义,不参与执行期改写;
// 2. 终审可基于它判断“来源任务是否真正迁移到目标区域”。
InitialHybridEntries []model.HybridScheduleEntry
HybridEntries []model.HybridScheduleEntry
AllocatedItems []model.TaskClassItem
CandidatePlans []model.UserWeekSchedule
// 3) 本轮执行状态
UserIntent string
Contract RefineContract
PlanMax int
PerTaskBudget int
ExecuteMax int
ReplanMax int
// CompositeRetryMax 表示复合路由失败后的最大重试次数(不含首次尝试)。
CompositeRetryMax int
PlanUsed int
ReplanUsed int
MaxRounds int
RepairReserve int
RoundUsed int
ActionLogs []string
ConsecutiveFailures int
ThinkingBoostArmed bool
ObservationHistory []ReactRoundObservation
CurrentPlan PlannerPlan
BatchMoveAllowed bool
// DisableCompositeTools=true 表示已进入 ReAct 兜底,禁止再调用复合工具。
DisableCompositeTools bool
// CompositeRouteTried 标记是否尝试过“复合批处理路由”。
CompositeRouteTried bool
// CompositeRouteSucceeded 标记复合批处理路由是否已完成“复合分支出站”。
//
// 说明:
// 1. true 表示当前链路可以跳过 ReAct 兜底,直接进入 hard_check
// 2. 它不等价于“终审已通过”,终审是否通过仍以后续 HardCheck 结果为准;
// 3. 这样区分是为了避免“复合工具已成功执行,但业务目标要等终审裁决”时被误判为失败。
CompositeRouteSucceeded bool
TaskActionUsed map[int]int
EntriesVersion int
SeenSlotQueries map[string]struct{}
// RequiredCompositeTool 表示本轮策略要求“必须至少成功一次”的复合工具。
// 取值约定:"" | "SpreadEven" | "MinContextSwitch"。
RequiredCompositeTool string
// CompositeToolCalled 记录复合工具是否至少调用过一次(不区分成功失败)。
CompositeToolCalled map[string]bool
// CompositeToolSuccess 记录复合工具是否至少成功过一次。
CompositeToolSuccess map[string]bool
SlicePlan RefineSlicePlan
Objective RefineObjective
WorksetTaskIDs []int
WorksetCursor int
CurrentTaskID int
CurrentTaskAttempt int
LastFailedCallSignature string
OriginOrderMap map[int]int
// 4) 终审状态
HardCheck HardCheckReport
// 5) 最终输出
FinalSummary string
Completed bool
}
// NewScheduleRefineState 基于上一版预览快照初始化状态。
//
// 职责边界:
// 1. 负责初始化预算、上下文字段与可变状态容器;
// 2. 负责拷贝 preview 数据,避免跨请求引用污染;
// 3. 不负责做任何调度动作。
func NewScheduleRefineState(traceID string, userID int, conversationID string, userMessage string, preview *model.SchedulePlanPreviewCache) *ScheduleRefineState {
now := nowToMinute()
st := &ScheduleRefineState{
TraceID: strings.TrimSpace(traceID),
UserID: userID,
ConversationID: strings.TrimSpace(conversationID),
UserMessage: strings.TrimSpace(userMessage),
RequestNow: now,
RequestNowText: now.In(loadLocation()).Format(datetimeLayout),
PlanMax: defaultPlanMax,
PerTaskBudget: defaultPerTaskBudget,
ExecuteMax: defaultExecuteMax,
ReplanMax: defaultReplanMax,
CompositeRetryMax: defaultCompositeRetry,
RepairReserve: defaultRepairReserve,
MaxRounds: defaultExecuteMax + defaultRepairReserve,
ActionLogs: make([]string, 0, 32),
ObservationHistory: make([]ReactRoundObservation, 0, 24),
TaskActionUsed: make(map[int]int),
SeenSlotQueries: make(map[string]struct{}),
OriginOrderMap: make(map[int]int),
CompositeToolCalled: map[string]bool{
"SpreadEven": false,
"MinContextSwitch": false,
},
CompositeToolSuccess: map[string]bool{
"SpreadEven": false,
"MinContextSwitch": false,
},
CurrentPlan: PlannerPlan{
Summary: "初始化完成,等待 Planner 生成执行计划。",
},
SlicePlan: RefineSlicePlan{
Reason: "尚未切片",
},
}
if preview == nil {
return st
}
st.TaskClassIDs = append([]int(nil), preview.TaskClassIDs...)
st.InitialHybridEntries = cloneHybridEntries(preview.HybridEntries)
st.HybridEntries = cloneHybridEntries(preview.HybridEntries)
st.AllocatedItems = cloneTaskClassItems(preview.AllocatedItems)
st.CandidatePlans = cloneWeekSchedules(preview.CandidatePlans)
st.OriginOrderMap = buildOriginOrderMap(st.HybridEntries)
return st
}
func loadLocation() *time.Location {
loc, err := time.LoadLocation(timezoneName)
if err != nil {
return time.Local
}
return loc
}
func nowToMinute() time.Time {
return time.Now().In(loadLocation()).Truncate(time.Minute)
}
func cloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry {
if len(src) == 0 {
return nil
}
dst := make([]model.HybridScheduleEntry, len(src))
copy(dst, src)
return dst
}
func cloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem {
if len(src) == 0 {
return nil
}
dst := make([]model.TaskClassItem, 0, len(src))
for _, item := range src {
copied := item
if item.CategoryID != nil {
v := *item.CategoryID
copied.CategoryID = &v
}
if item.Order != nil {
v := *item.Order
copied.Order = &v
}
if item.Content != nil {
v := *item.Content
copied.Content = &v
}
if item.Status != nil {
v := *item.Status
copied.Status = &v
}
if item.EmbeddedTime != nil {
t := *item.EmbeddedTime
copied.EmbeddedTime = &t
}
dst = append(dst, copied)
}
return dst
}
func cloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule {
if len(src) == 0 {
return nil
}
dst := make([]model.UserWeekSchedule, 0, len(src))
for _, week := range src {
eventsCopy := make([]model.WeeklyEventBrief, len(week.Events))
copy(eventsCopy, week.Events)
dst = append(dst, model.UserWeekSchedule{
Week: week.Week,
Events: eventsCopy,
})
}
return dst
}
// buildOriginOrderMap 构建 suggested 任务的初始顺序基线task_item_id -> rank
func buildOriginOrderMap(entries []model.HybridScheduleEntry) map[int]int {
orderMap := make(map[int]int)
if len(entries) == 0 {
return orderMap
}
suggested := make([]model.HybridScheduleEntry, 0, len(entries))
for _, entry := range entries {
if isMovableSuggestedTask(entry) {
suggested = append(suggested, entry)
}
}
sort.SliceStable(suggested, func(i, j int) bool {
left := suggested[i]
right := suggested[j]
if left.Week != right.Week {
return left.Week < right.Week
}
if left.DayOfWeek != right.DayOfWeek {
return left.DayOfWeek < right.DayOfWeek
}
if left.SectionFrom != right.SectionFrom {
return left.SectionFrom < right.SectionFrom
}
if left.SectionTo != right.SectionTo {
return left.SectionTo < right.SectionTo
}
return left.TaskItemID < right.TaskItemID
})
for i, entry := range suggested {
orderMap[entry.TaskItemID] = i + 1
}
return orderMap
}
// FinalHardCheckPassed 判断“最终终审”是否整体通过。
//
// 职责边界:
// 1. 负责聚合 physics/order/intent 三类硬校验结果,给服务层与总结阶段统一复用;
// 2. 不负责触发终审,也不负责推导修复动作;
// 3. nil state 视为未通过,避免上层把缺失结果误判为成功。
func FinalHardCheckPassed(st *ScheduleRefineState) bool {
if st == nil {
return false
}
return st.HardCheck.PhysicsPassed && st.HardCheck.OrderPassed && st.HardCheck.IntentPassed
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
package agentnode
// schedule_refine_tool.go keeps the dual-file layout stable during migration.
// The concrete refine tool implementation remains in schedule_refine_impl for now.

View File

@@ -0,0 +1,131 @@
package agentprompt
const (
ScheduleRefineContractPrompt = `You are SmartFlow's schedule refine contract analyzer.
Return exactly one JSON object.
Schema:
{
"intent": "short summary",
"strategy": "local_adjust|keep",
"hard_requirements": ["..."],
"hard_assertions": [
{
"metric": "source_move_ratio_percent|all_source_tasks_in_target_scope|source_remaining_count",
"operator": "==|<=|>=|between",
"value": 50,
"min": 50,
"max": 50,
"week": 17,
"target_week": 16
}
],
"keep_relative_order": true,
"order_scope": "global|week"
}
Rules:
- Default keep_relative_order=true unless the user explicitly allows reordering.
- If tasks are being moved, strategy must be local_adjust.
- hard_requirements must be concrete and verifiable.
- hard_assertions should be as structured as possible.`
ScheduleRefinePlannerPrompt = `You are SmartFlow's schedule refine planner.
Return exactly one JSON object:
{
"summary": "one sentence",
"steps": ["step1","step2","step3"]
}
Rules:
- Keep 3-4 steps.
- Prefer "inspect first, then act".
- If the goal is even spreading, the steps must mention SpreadEven and success gating.
- If the goal is minimizing context switching, the steps must mention MinContextSwitch and success gating.`
ScheduleRefineReactPrompt = `You are SmartFlow's single-task micro ReAct executor.
You may do exactly one thing each round:
1. call one tool
2. return done=true
Tool groups:
- Basic: QueryTargetTasks, QueryAvailableSlots, Move, Swap, BatchMove, Verify
- Composite: SpreadEven, MinContextSwitch
Return exactly one JSON object:
{
"done": false,
"summary": "",
"goal_check": "",
"decision": "",
"missing_info": [],
"tool_calls": [
{
"tool": "QueryTargetTasks|QueryAvailableSlots|Move|Swap|BatchMove|SpreadEven|MinContextSwitch|Verify",
"params": {}
}
]
}
Rules:
- At most one tool call.
- If done=true, tool_calls must be [].
- Only modify suggested tasks.
- Do not invent tools.
- Respect REQUIRED_COMPOSITE_TOOL and COMPOSITE_TOOLS_ALLOWED.`
ScheduleRefinePostReflectPrompt = `You are SmartFlow's post-tool reflector.
Return exactly one JSON object:
{
"reflection": "",
"next_strategy": "",
"should_stop": false
}
Rules:
- Base the reflection on the real tool result only.
- If the tool failed, explain the failure reason.
- If should_stop=true, it must mean the goal is already met or further work has low value.`
ScheduleRefineReviewPrompt = `You are SmartFlow's final refine reviewer.
Return exactly one JSON object:
{
"pass": true,
"reason": "",
"unmet": []
}
Rules:
- If pass=true, unmet must be [].
- If pass=false, reason must state the core gap.`
ScheduleRefineSummaryPrompt = `You are SmartFlow's result summarizer.
Write a short user-facing summary in 2-4 Chinese sentences:
1. what changed
2. what benefit was achieved
3. if final review still failed, what remains`
ScheduleRefineRepairPrompt = `You are SmartFlow's one-step repair executor.
The current plan failed final review.
Return exactly one JSON object with exactly one tool call:
{
"done": false,
"summary": "",
"goal_check": "",
"decision": "",
"missing_info": [],
"tool_calls": [
{
"tool": "Move|Swap",
"params": {}
}
]
}
Use standard Move keys only:
- task_item_id
- to_week
- to_day
- to_section_from
- to_section_to`
)

View File

@@ -1,82 +1,82 @@
# agent2 通用能力接入文档
# agent2 閫氱敤鑳藉姏鎺ュ叆鏂囨。
## 1. 文档目的
## 1. 鏂囨。鐩殑
本文档用于说明 `agent2` 目录下“通用能力”的边界、放置位置、接入方式与维护要求。
鏈枃妗g敤浜庤鏄?`agent2` 鐩綍涓嬧€滈€氱敤鑳藉姏鈥濈殑杈圭晫銆佹斁缃綅缃€佹帴鍏ユ柟寮忎笌缁存姢瑕佹眰銆?
这里的“通用能力”特指:
杩欓噷鐨勨€滈€氱敤鑳藉姏鈥濈壒鎸囷細
1. 不只服务于某一个技能链路,而是可能被 `chat``quicknote``taskquery``schedule` 等多个模块共同复用的能力。
2. 与具体业务语义弱耦合,抽出来后不会强行把某个单一技能的 prompt、状态字段、业务规则污染到其它模块。
3. 抽出来后,能够明显减少样板代码、降低重复实现和后续迁移成本。
1. 涓嶅彧鏈嶅姟浜庢煇涓€涓妧鑳介摼璺紝鑰屾槸鍙兘琚?`chat`銆乣quicknote`銆乣taskquery`銆乣schedule` 绛夊涓ā鍧楀叡鍚屽鐢ㄧ殑鑳藉姏銆?
2. 涓庡叿浣撲笟鍔¤涔夊急鑰﹀悎锛屾娊鍑烘潵鍚庝笉浼氬己琛屾妸鏌愪釜鍗曚竴鎶€鑳界殑 prompt銆佺姸鎬佸瓧娈点€佷笟鍔¤鍒欐薄鏌撳埌鍏跺畠妯″潡銆?
3. 鎶藉嚭鏉ュ悗锛岃兘澶熸槑鏄惧噺灏戞牱鏉夸唬鐮併€侀檷浣庨噸澶嶅疄鐜板拰鍚庣画杩佺Щ鎴愭湰銆?
本文档不负责描述某个具体技能的业务流程技能自身的图编排、状态字段、prompt 细节,应继续放在对应技能目录或对应决策记录中维护。
鏈枃妗笉璐熻矗鎻忚堪鏌愪釜鍏蜂綋鎶€鑳界殑涓氬姟娴佺▼锛屾妧鑳借嚜韬殑鍥剧紪鎺掋€佺姸鎬佸瓧娈点€乸rompt 缁嗚妭锛屽簲缁х画鏀惧湪瀵瑰簲鎶€鑳界洰褰曟垨瀵瑰簲鍐崇瓥璁板綍涓淮鎶ゃ€?
## 2. 当前目录分层
## 2. 褰撳墠鐩綍鍒嗗眰
### 2.1 总入口层
### 2.1 鎬诲叆鍙e眰
文件:
鏂囦欢锛?
- `entrance.go`
职责:
鑱岃矗锛?
1. 作为 `agent2` 模块对上层服务的统一入口。
2. 负责把“路由器 + 各技能 handler”装配到一起。
3. 不负责具体技能逻辑,不负责直接调模型,也不负责工具执行。
1. 浣滀负 `agent2` 妯″潡瀵逛笂灞傛湇鍔$殑缁熶竴鍏ュ彛銆?
2. 璐熻矗鎶娾€滆矾鐢卞櫒 + 鍚勬妧鑳?handler鈥濊閰嶅埌涓€璧枫€?
3. 涓嶈礋璐e叿浣撴妧鑳介€昏緫锛屼笉璐熻矗鐩存帴璋冩ā鍨嬶紝涔熶笉璐熻矗宸ュ叿鎵ц銆?
适合放什么:
閫傚悎鏀句粈涔堬細
1. 模块级入口对象。
2. 通用注册方法。
3. 与“总分发”有关的最小门面封装。
1. 妯″潡绾у叆鍙e璞°€?
2. 閫氱敤娉ㄥ唽鏂规硶銆?
3. 涓庘€滄€诲垎鍙戔€濇湁鍏崇殑鏈€灏忛棬闈㈠皝瑁呫€?
不适合放什么:
涓嶉€傚悎鏀句粈涔堬細
1. 某个具体技能的节点逻辑。
2. 具体业务 DAO 调用。
3. 某个技能独占的 prompt 或状态机。
1. 鏌愪釜鍏蜂綋鎶€鑳界殑鑺傜偣閫昏緫銆?
2. 鍏蜂綋涓氬姟 DAO 璋冪敤銆?
3. 鏌愪釜鎶€鑳界嫭鍗犵殑 prompt 鎴栫姸鎬佹満銆?
### 2.2 路由层
### 2.2 璺敱灞?
目录:
鐩綍锛?
- `router/`
当前通用能力:
褰撳墠閫氱敤鑳藉姏锛?
1. `Dispatcher`
2. `Resolver`
3. `AgentRequest / AgentResponse`
4. `Action` 与路由控制码解析
4. `Action` 涓庤矾鐢辨帶鍒剁爜瑙f瀽
职责:
鑱岃矗锛?
1. 统一处理“请求该走哪条技能链路”的分流问题。
2. 提供对上层稳定的动作枚举与请求壳结构。
3. 兼容迁移期的新旧 action 语义,避免上层服务直接依赖旧目录。
1. 缁熶竴澶勭悊鈥滆姹傝璧板摢鏉℃妧鑳介摼璺€濈殑鍒嗘祦闂銆?
2. 鎻愪緵瀵逛笂灞傜ǔ瀹氱殑鍔ㄤ綔鏋氫妇涓庤姹傚3缁撴瀯銆?
3. 鍏煎杩佺Щ鏈熺殑鏂版棫 action 璇箟锛岄伩鍏嶄笂灞傛湇鍔$洿鎺ヤ緷璧栨棫鐩綍銆?
适合放什么:
閫傚悎鏀句粈涔堬細
1. 通用路由协议。
2. 控制码解析。
3. 分发器。
4. 所有技能共用的路由请求/响应结构。
1. 閫氱敤璺敱鍗忚銆?
2. 鎺у埗鐮佽В鏋愩€?
3. 鍒嗗彂鍣ㄣ€?
4. 鎵€鏈夋妧鑳藉叡鐢ㄧ殑璺敱璇锋眰/鍝嶅簲缁撴瀯銆?
不适合放什么:
涓嶉€傚悎鏀句粈涔堬細
1. 某个技能内部的二次判断。
2. 某个技能专属的 prompt
3. 技能内部重试或状态流转逻辑。
1. 鏌愪釜鎶€鑳藉唴閮ㄧ殑浜屾鍒ゆ柇銆?
2. 鏌愪釜鎶€鑳戒笓灞炵殑 prompt銆?
3. 鎶€鑳藉唴閮ㄩ噸璇曟垨鐘舵€佹祦杞€昏緫銆?
### 2.3 模型交互层
### 2.3 妯″瀷浜や簰灞?
目录:
鐩綍锛?
- `llm/`
当前通用能力:
褰撳墠閫氱敤鑳藉姏锛?
1. `Client`
2. `GenerateOptions`
@@ -85,96 +85,96 @@
5. `GenerateJSON`
6. `ParseJSONObject`
7. `MergeUsage / CloneUsage`
8. `ark.go` 中的 Ark 适配实现
8. `ark.go` 涓殑 Ark 閫傞厤瀹炵幇
职责:
鑱岃矗锛?
1. 统一收口模型调用接口,减少各技能重复拼装 `messages``thinking``temperature``max_tokens`
2. 提供通用 JSON 解析与 usage 合并能力。
3. 把具体厂商 SDK 细节尽量压在适配层,不向上层节点扩散。
1. 缁熶竴鏀跺彛妯″瀷璋冪敤鎺ュ彛锛屽噺灏戝悇鎶€鑳介噸澶嶆嫾瑁?`messages`銆乣thinking`銆乣temperature`銆乣max_tokens`銆?
2. 鎻愪緵閫氱敤 JSON 瑙f瀽涓?usage 鍚堝苟鑳藉姏銆?
3. 鎶婂叿浣撳巶鍟?SDK 缁嗚妭灏介噺鍘嬪湪閫傞厤灞傦紝涓嶅悜涓婂眰鑺傜偣鎵╂暎銆?
适合放什么:
閫傚悎鏀句粈涔堬細
1. 所有技能都可能复用的模型调用壳。
2. 通用 JSON 提取与反序列化。
3. 流式/非流式调用的统一适配接口。
4. usage 收敛、空响应错误格式化。
1. 鎵€鏈夋妧鑳介兘鍙兘澶嶇敤鐨勬ā鍨嬭皟鐢ㄥ3銆?
2. 閫氱敤 JSON 鎻愬彇涓庡弽搴忓垪鍖栥€?
3. 娴佸紡/闈炴祦寮忚皟鐢ㄧ殑缁熶竴閫傞厤鎺ュ彛銆?
4. usage 鏀舵暃銆佺┖鍝嶅簲閿欒鏍煎紡鍖栥€?
不适合放什么:
涓嶉€傚悎鏀句粈涔堬細
1. 只服务于某一个技能的 prompt 文案。
2. 某一个技能特有的输出结构体。
3. 某一个技能独占的“成功文案润色”规则。
1. 鍙湇鍔′簬鏌愪竴涓妧鑳界殑 prompt 鏂囨銆?
2. 鏌愪竴涓妧鑳界壒鏈夌殑杈撳嚭缁撴瀯浣撱€?
3. 鏌愪竴涓妧鑳界嫭鍗犵殑鈥滄垚鍔熸枃妗堟鼎鑹测€濊鍒欍€?
说明:
璇存槑锛?
1. 如果只是“基于通用 `Client` 再包一层技能专用函数”,例如 quicknote 的聚合规划调用,这种代码可以放在 `llm/`,但文件名应明确带技能语义,避免误认为完全通用能力。
2. 真正跨技能复用的内容,优先沉到 `client.go``ark.go``json.go` 这类公共文件。
1. 濡傛灉鍙槸鈥滃熀浜庨€氱敤 `Client` 鍐嶅寘涓€灞傛妧鑳戒笓鐢ㄥ嚱鏁扳€濓紝渚嬪 quicknote 鐨勮仛鍚堣鍒掕皟鐢紝杩欑浠g爜鍙互鏀惧湪 `llm/`锛屼絾鏂囦欢鍚嶅簲鏄庣‘甯︽妧鑳借涔夛紝閬垮厤璇涓哄畬鍏ㄩ€氱敤鑳藉姏銆?
2. 鐪熸璺ㄦ妧鑳藉鐢ㄧ殑鍐呭锛屼紭鍏堟矇鍒?`client.go`銆乣ark.go`銆乣json.go` 杩欑被鍏叡鏂囦欢銆?
### 2.4 流输出协议层
### 2.4 娴佽緭鍑哄崗璁眰
目录:
鐩綍锛?
- `stream/`
当前通用能力:
褰撳墠閫氱敤鑳藉姏锛?
1. OpenAI 兼容 chunk DTO
2. reasoning chunk 构造
3. assistant chunk 构造
4. finish / done 输出
5. 阶段推送 emitter
1. OpenAI 鍏煎 chunk DTO
2. reasoning chunk 鏋勯€?
3. assistant chunk 鏋勯€?
4. finish / done 杈撳嚭
5. 闃舵鎺ㄩ€?emitter
职责:
鑱岃矗锛?
1. 统一处理 SSE / OpenAI 兼容输出格式。
2. service、graph、node 只关心“我要推什么内容”,而不是自己拼 JSON
3. 为后续前端协议升级预留统一修改点。
1. 缁熶竴澶勭悊 SSE / OpenAI 鍏煎杈撳嚭鏍煎紡銆?
2. 璁?service銆乬raph銆乶ode 鍙叧蹇冣€滄垜瑕佹帹浠€涔堝唴瀹光€濓紝鑰屼笉鏄嚜宸辨嫾 JSON銆?
3. 涓哄悗缁墠绔崗璁崌绾ч鐣欑粺涓€淇敼鐐广€?
适合放什么:
閫傚悎鏀句粈涔堬細
1. chunk DTO
2. reasoning / content / finish 的统一封装。
3. 阶段消息推送器接口。
1. chunk DTO銆?
2. reasoning / content / finish 鐨勭粺涓€灏佽銆?
3. 闃舵娑堟伅鎺ㄩ€佸櫒鎺ュ彛銆?
不适合放什么:
涓嶉€傚悎鏀句粈涔堬細
1. 某个技能的阶段命名表。
2. 某个技能专属的正文文案。
3. 具体业务状态对象。
1. 鏌愪釜鎶€鑳界殑闃舵鍛藉悕琛ㄣ€?
2. 鏌愪釜鎶€鑳戒笓灞炵殑姝f枃鏂囨銆?
3. 鍏蜂綋涓氬姟鐘舵€佸璞°€?
### 2.5 共享工具层
### 2.5 鍏变韩宸ュ叿灞?
目录:
鐩綍锛?
- `shared/`
当前通用能力:
褰撳墠閫氱敤鑳藉姏锛?
1. 时间格式化与分钟级归一化
2. 深拷贝
3. 通用重试辅助
1. 鏃堕棿鏍煎紡鍖栦笌鍒嗛挓绾у綊涓€鍖?
2. 娣辨嫹璐?
3. 閫氱敤閲嶈瘯杈呭姪
职责:
鑱岃矗锛?
1. 承载纯工具型、无业务语义、无技能耦合的辅助函数。
2. 作为多个技能都能直接复用的最底层工具层。
1. 鎵胯浇绾伐鍏峰瀷銆佹棤涓氬姟璇箟銆佹棤鎶€鑳借€﹀悎鐨勮緟鍔╁嚱鏁般€?
2. 浣滀负澶氫釜鎶€鑳介兘鑳界洿鎺ュ鐢ㄧ殑鏈€搴曞眰宸ュ叿灞傘€?
适合放什么:
閫傚悎鏀句粈涔堬細
1. 时间工具。
2. clone 工具。
3. retry 帮助函数。
4. 纯函数型小工具。
1. 鏃堕棿宸ュ叿銆?
2. clone 宸ュ叿銆?
3. retry 甯姪鍑芥暟銆?
4. 绾嚱鏁板瀷灏忓伐鍏枫€?
不适合放什么:
涓嶉€傚悎鏀句粈涔堬細
1. 夹带具体技能字段名的工具。
2. 依赖数据库、缓存、模型、路由动作的逻辑。
1. 澶瑰甫鍏蜂綋鎶€鑳藉瓧娈靛悕鐨勫伐鍏枫€?
2. 渚濊禆鏁版嵁搴撱€佺紦瀛樸€佹ā鍨嬨€佽矾鐢卞姩浣滅殑閫昏緫銆?
### 2.6 技能内部层
### 2.6 鎶€鑳藉唴閮ㄥ眰
目录:
鐩綍锛?
- `graph/`
- `node/`
@@ -182,156 +182,165 @@
- `model/`
- `chat/`
职责:
鑱岃矗锛?
1. 这几层主要承载技能内部能力。
2. 即使其中某个文件现在位于 `agent2` 根体系内,只要它带明显技能语义,就不要误判成“通用能力”。
1. 杩欏嚑灞備富瑕佹壙杞芥妧鑳藉唴閮ㄨ兘鍔涖€?
2. 鍗充娇鍏朵腑鏌愪釜鏂囦欢鐜板湪浣嶄簬 `agent2` 鏍逛綋绯诲唴锛屽彧瑕佸畠甯︽槑鏄炬妧鑳借涔夛紝灏变笉瑕佽鍒ゆ垚鈥滈€氱敤鑳藉姏鈥濄€?
判断标准:
鍒ゆ柇鏍囧噯锛?
1. 如果代码里天然绑定某个技能状态结构、某个技能阶段名、某个技能 prompt 输出契约,一般不应硬抽成通用能力。
2. 如果只是多个技能都重复了同一段样板代码,且抽出后不会让抽象变形,就应该评估下沉。
1. 濡傛灉浠g爜閲屽ぉ鐒剁粦瀹氭煇涓妧鑳界姸鎬佺粨鏋勩€佹煇涓妧鑳介樁娈靛悕銆佹煇涓妧鑳?prompt 杈撳嚭濂戠害锛屼竴鑸笉搴旂‖鎶芥垚閫氱敤鑳藉姏銆?
2. 濡傛灉鍙槸澶氫釜鎶€鑳介兘閲嶅浜嗗悓涓€娈垫牱鏉夸唬鐮侊紝涓旀娊鍑哄悗涓嶄細璁╂娊璞″彉褰紝灏卞簲璇ヨ瘎浼颁笅娌夈€?
### 2.7 图层与节点层的协作约定
### 2.7 鍥惧眰涓庤妭鐐瑰眰鐨勫崗浣滅害瀹?
这是当前 `agent2` 已经明确下来的结构约束:
杩欐槸褰撳墠 `agent2` 宸茬粡鏄庣‘涓嬫潵鐨勭粨鏋勭害鏉燂細
1. `graph/` 只负责“画图”:
- 注册节点
- 添加边
- 添加分支
- 编译与运行图
2. `graph/` 不再负责:
- 额外创建 runner 适配层
- 在图内继续堆请求级依赖转发逻辑
- 直接实现节点业务
3. `node/` 负责:
- 定义节点容器(例如 `QuickNoteNodes`
- 通过对象方法直接向 graph 暴露可挂载节点
- 在节点方法内部消费请求级依赖
1. `graph/` 鍙礋璐b€滅敾鍥锯€濓細
- 娉ㄥ唽鑺傜偣
- 娣诲姞杈?
- 娣诲姞鍒嗘敮
- 缂栬瘧涓庤繍琛屽浘
2. `graph/` 涓嶅啀璐熻矗锛?
- 棰濆鍒涘缓 runner 閫傞厤灞?
- 鍦ㄥ浘鍐呯户缁爢璇锋眰绾т緷璧栬浆鍙戦€昏緫
- 鐩存帴瀹炵幇鑺傜偣涓氬姟
3. `node/` 璐熻矗锛?
- 瀹氫箟鑺傜偣瀹瑰櫒锛堜緥濡?`QuickNoteNodes`锛?
- 閫氳繃瀵硅薄鏂规硶鐩存帴鍚?graph 鏆撮湶鍙寕杞借妭鐐?
- 鍦ㄨ妭鐐规柟娉曞唴閮ㄦ秷璐硅姹傜骇渚濊禆
推荐形态:
鎺ㄨ崘褰㈡€侊細
1. `graph` 里直接挂:
1. `graph` 閲岀洿鎺ユ寕锛?
- `nodes.Intent`
- `nodes.Priority`
- `nodes.Persist`
- `nodes.Exit`
2. 分支也直接挂:
2. 鍒嗘敮涔熺洿鎺ユ寕锛?
- `nodes.NextAfterIntent`
- `nodes.NextAfterPersist`
3. 不推荐再额外引入 `runner -> node` 这种转接层。
3. 涓嶆帹鑽愬啀棰濆寮曞叆 `runner -> node` 杩欑杞帴灞傘€?
这样设计的目的:
杩欐牱璁捐鐨勭洰鐨勶細
1. 避免 graph 文件随着模块变多再次长成“大装配文件”。
2. 让“请求级依赖注入”回到 node 层自己的节点容器里。
3. 让阅读路径稳定成:
- 先看 graph 知道流程图
- 再跳 node 看节点方法实现
- 不需要在 graph runner 两层之间来回跳。
1. 閬垮厤 graph 鏂囦欢闅忕潃妯″潡鍙樺鍐嶆闀挎垚鈥滃ぇ瑁呴厤鏂囦欢鈥濄€?
2. 璁┾€滆姹傜骇渚濊禆娉ㄥ叆鈥濆洖鍒?node 灞傝嚜宸辩殑鑺傜偣瀹瑰櫒閲屻€?
3. 璁╅槄璇昏矾寰勭ǔ瀹氭垚锛?
- 鍏堢湅 graph 鐭ラ亾娴佺▼鍥?
- 鍐嶈烦 node 鐪嬭妭鐐规柟娉曞疄鐜?
- 涓嶉渶瑕佸湪 graph 鍜?runner 涓ゅ眰涔嬮棿鏉ュ洖璺炽€?
## 3. 什么应该抽成通用能力
## 3. 浠€涔堝簲璇ユ娊鎴愰€氱敤鑳藉姏
满足以下任意两条,一般就应该认真评估抽公共层:
婊¤冻浠ヤ笅浠绘剰涓ゆ潯锛屼竴鑸氨搴旇璁ょ湡璇勪及鎶藉叕鍏卞眰锛?
1. 在第二个技能里出现了明显重复实现。
2. 这段逻辑本质上不依赖某个技能独占状态。
3. 抽出来后接口可以做到“入参少、职责清、语义稳定”。
4. 上层重复代码主要是在做样板装配,而不是业务决策。
1. 鍦ㄧ浜屼釜鎶€鑳介噷鍑虹幇浜嗘槑鏄鹃噸澶嶅疄鐜般€?
2. 杩欐閫昏緫鏈川涓婁笉渚濊禆鏌愪釜鎶€鑳界嫭鍗犵姸鎬併€?
3. 鎶藉嚭鏉ュ悗鎺ュ彛鍙互鍋氬埌鈥滃叆鍙傚皯銆佽亴璐f竻銆佽涔夌ǔ瀹氣€濄€?
4. 涓婂眰閲嶅浠g爜涓昏鏄湪鍋氭牱鏉胯閰嶏紝鑰屼笉鏄笟鍔″喅绛栥€?
典型例子:
鍏稿瀷渚嬪瓙锛?
1. 模型消息拼装。
2. JSON 提取与解析。
3. usage 合并。
4. OpenAI chunk 构造。
5. 时间归一化。
6. 深拷贝与重试工具。
7. 总入口路由与技能分发。
1. 妯″瀷娑堟伅鎷艰銆?
2. JSON 鎻愬彇涓庤В鏋愩€?
3. usage 鍚堝苟銆?
4. OpenAI chunk 鏋勯€犮€?
5. 鏃堕棿褰掍竴鍖栥€?
6. 娣辨嫹璐濅笌閲嶈瘯宸ュ叿銆?
7. 鎬诲叆鍙h矾鐢变笌鎶€鑳藉垎鍙戙€?
## 4. 什么不应该强行抽公共层
## 4. 浠€涔堜笉搴旇寮鸿鎶藉叕鍏卞眰
出现以下情况时,不要为了“看起来复用”而硬抽:
鍑虹幇浠ヤ笅鎯呭喌鏃讹紝涓嶈涓轰簡鈥滅湅璧锋潵澶嶇敤鈥濊€岀‖鎶斤細
1. 抽完之后函数签名反而要塞一堆技能专属参数。
2. 公共层开始知道某个技能的状态字段、阶段名、错误文案。
3. 表面相似,实则每个技能的业务约束完全不同。
4. 为了复用而引入大量 `if action == xxx``switch skill` 这类分支。
1. 鎶藉畬涔嬪悗鍑芥暟绛惧悕鍙嶈€岃濉炰竴鍫嗘妧鑳戒笓灞炲弬鏁般€?
2. 鍏叡灞傚紑濮嬬煡閬撴煇涓妧鑳界殑鐘舵€佸瓧娈点€侀樁娈靛悕銆侀敊璇枃妗堛€?
3. 琛ㄩ潰鐩镐技锛屽疄鍒欐瘡涓妧鑳界殑涓氬姟绾︽潫瀹屽叏涓嶅悓銆?
4. 涓轰簡澶嶇敤鑰屽紩鍏ュぇ閲?`if action == xxx`銆乣switch skill` 杩欑被鍒嗘敮銆?
典型例子:
鍏稿瀷渚嬪瓙锛?
1. quicknote 的优先级判定输出结构。
2. taskquery 的查询规划字段。
3. schedule 的排程状态快照。
4. 某个技能特有的 prompt 模板。
1. quicknote 鐨勪紭鍏堢骇鍒ゅ畾杈撳嚭缁撴瀯銆?
2. taskquery 鐨勬煡璇㈣鍒掑瓧娈点€?
3. schedule 鐨勬帓绋嬬姸鎬佸揩鐓с€?
4. 鏌愪釜鎶€鑳界壒鏈夌殑 prompt 妯℃澘銆?
## 5. 新增通用能力的接入步骤
## 5. 鏂板閫氱敤鑳藉姏鐨勬帴鍏ユ楠?
### 5.1 先判断是否值得抽
### 5.1 鍏堝垽鏂槸鍚﹀€煎緱鎶?
1. 先确认这段逻辑是否已经在第二处出现重复。
2. 再确认它是不是可以脱离单一技能语义独立存在。
3. 如果暂时还不能抽,也要在代码注释或决策记录里写明原因,避免后面第三次重复时忘记。
1. 鍏堢‘璁よ繖娈甸€昏緫鏄惁宸茬粡鍦ㄧ浜屽鍑虹幇閲嶅銆?
2. 鍐嶇‘璁ゅ畠鏄笉鏄彲浠ヨ劚绂诲崟涓€鎶€鑳借涔夌嫭绔嬪瓨鍦ㄣ€?
3. 濡傛灉鏆傛椂杩樹笉鑳芥娊锛屼篃瑕佸湪浠g爜娉ㄩ噴鎴栧喅绛栬褰曢噷鍐欐槑鍘熷洜锛岄伩鍏嶅悗闈㈢涓夋閲嶅鏃跺繕璁般€?
### 5.2 选择落点目录
### 5.2 閫夋嫨钀界偣鐩綍
按职责优先落到以下目录:
鎸夎亴璐d紭鍏堣惤鍒颁互涓嬬洰褰曪細
1. 路由协议与分发:`router/`
2. 模型调用与 JSON 解析:`llm/`
3. 流输出协议:`stream/`
4. 纯工具:`shared/`
5. 技能专属但可复用的壳:放对应技能语义文件,不要伪装成完全公共层
1. 璺敱鍗忚涓庡垎鍙戯細`router/`
2. 妯″瀷璋冪敤涓?JSON 瑙f瀽锛歚llm/`
3. 娴佽緭鍑哄崗璁細`stream/`
4. 绾伐鍏凤細`shared/`
5. 鎶€鑳戒笓灞炰絾鍙鐢ㄧ殑澹筹細鏀惧搴旀妧鑳借涔夋枃浠讹紝涓嶈浼鎴愬畬鍏ㄥ叕鍏卞眰
### 5.3 定义最小接口
### 5.3 瀹氫箟鏈€灏忔帴鍙?
1. 先定义最小可复用接口,只暴露上层真正需要的能力。
2. 不要把下层 SDK、DAO、缓存实现细节直接透传到所有调用方。
3. 优先让“公共层知道得更少”,而不是让“上层为了复用被迫知道更多”。
1. 鍏堝畾涔夋渶灏忓彲澶嶇敤鎺ュ彛锛屽彧鏆撮湶涓婂眰鐪熸闇€瑕佺殑鑳藉姏銆?
2. 涓嶈鎶婁笅灞?SDK銆丏AO銆佺紦瀛樺疄鐜扮粏鑺傜洿鎺ラ€忎紶鍒版墍鏈夎皟鐢ㄦ柟銆?
3. 浼樺厛璁┾€滃叕鍏卞眰鐭ラ亾寰楁洿灏戔€濓紝鑰屼笉鏄鈥滀笂灞備负浜嗗鐢ㄨ杩煡閬撴洿澶氣€濄€?
### 5.4 补注释
### 5.4 琛ユ敞閲?
必须写清楚:
蹇呴』鍐欐竻妤氾細
1. 这个通用能力负责什么。
2. 不负责什么。
3. 为什么它适合抽到公共层。
4. 失败时由谁兜底。
1. 杩欎釜閫氱敤鑳藉姏璐熻矗浠€涔堛€?
2. 涓嶈礋璐d粈涔堛€?
3. 涓轰粈涔堝畠閫傚悎鎶藉埌鍏叡灞傘€?
4. 澶辫触鏃剁敱璋佸厹搴曘€?
### 5.5 补测试
### 5.5 琛ユ祴璇?
至少覆盖:
鑷冲皯瑕嗙洊锛?
1. 正常路径。
2. 关键边界。
3. 明确的失败路径。
1. 姝e父璺緞銆?
2. 鍏抽敭杈圭晫銆?
3. 鏄庣‘鐨勫け璐ヨ矾寰勩€?
如果迁移期暂时没法完整补齐,也要优先保证公共函数本身有最小回归测试。
濡傛灉杩佺Щ鏈熸殏鏃舵病娉曞畬鏁磋ˉ榻愶紝涔熻浼樺厛淇濊瘉鍏叡鍑芥暟鏈韩鏈夋渶灏忓洖褰掓祴璇曘€?
### 5.6 更新本文档
### 5.6 鏇存柊鏈枃妗?
只要出现以下任一情况,必须同步更新本文档:
鍙鍑虹幇浠ヤ笅浠讳竴鎯呭喌锛屽繀椤诲悓姝ユ洿鏂版湰鏂囨。锛?
1. 新增了一个通用能力。
2. 调整了某个通用能力的落点目录。
3. 修改了某个公共接口的职责边界。
4. 删掉了某个旧的公共实现,并由新实现替代。
1. 鏂板浜嗕竴涓€氱敤鑳藉姏銆?
2. 璋冩暣浜嗘煇涓€氱敤鑳藉姏鐨勮惤鐐圭洰褰曘€?
3. 淇敼浜嗘煇涓叕鍏辨帴鍙g殑鑱岃矗杈圭晫銆?
4. 鍒犳帀浜嗘煇涓棫鐨勫叕鍏卞疄鐜帮紝骞剁敱鏂板疄鐜版浛浠c€?
## 6. 推荐接入模板
## 6. 鎺ㄨ崘鎺ュ叆妯℃澘
可以按下面这个思路接入:
鍙互鎸変笅闈㈣繖涓€濊矾鎺ュ叆锛?
1. 先在技能代码里识别重复片段。
2. 提炼出“最小公共函数 / 最小公共结构体 / 最小公共接口”。
3. 放进 `router / llm / stream / shared` 之一。
4. 先让新技能接这个公共实现。
5. 再逐步回收旧技能里重复的老代码。
6. 最后补本文档,说明这个能力现在归谁管、上层该怎么用。
1. 鍏堝湪鎶€鑳戒唬鐮侀噷璇嗗埆閲嶅鐗囨銆?
2. 鎻愮偧鍑衡€滄渶灏忓叕鍏卞嚱鏁?/ 鏈€灏忓叕鍏辩粨鏋勪綋 / 鏈€灏忓叕鍏辨帴鍙b€濄€?
3. 鏀捐繘 `router / llm / stream / shared` 涔嬩竴銆?
4. 鍏堣鏂版妧鑳芥帴杩欎釜鍏叡瀹炵幇銆?
5. 鍐嶉€愭鍥炴敹鏃ф妧鑳介噷閲嶅鐨勮€佷唬鐮併€?
6. 鏈€鍚庤ˉ鏈枃妗o紝璇存槑杩欎釜鑳藉姏鐜板湪褰掕皝绠°€佷笂灞傝鎬庝箞鐢ㄣ€?
## 7. 当前维护要求
## 7. 褰撳墠缁存姢瑕佹眰
1. `agent2` 鐨勫叕鍏卞眰瑕佸敖閲忎繚鎸佲€滀綆鑰﹀悎銆佸己娉ㄩ噴銆佹槗杩佺Щ鈥濄€?
2. 鏂版妧鑳藉紑鍙戞椂锛屼紭鍏堝鐢ㄨ繖閲屽凡鏈夌殑鍏叡鑳藉姏锛岃€屼笉鏄洿鎺ュ鍒舵棫鎶€鑳戒唬鐮併€?
3. 濡傛灉鍙戠幇鏌愭閫昏緫宸茬粡鍑虹幇绗簩浠藉疄鐜帮紝搴斾紭鍏堣瘎浼版娊鍏叡灞傦紝鑰屼笉鏄户缁啓绗笁浠姐€?
4. 鍚庣画鍙鎵╁睍閫氱敤鑳藉姏锛屽繀椤诲悓姝ユ洿鏂版湰鏂囨。锛屽惁鍒欒涓鸿縼绉绘垨閲嶆瀯鏈畬鎴愩€?
## 8. 2026-03-25 schedule_refine 鎺ュ叆璁板綍
1. 鏂板 `agent2/node/schedule_refine_impl` 鐩綍锛屽鍒舵壙鎺ュ師 `agent/schedulerefine` 鍏ㄩ噺杩炵画寰皟瀹炵幇锛坓raph + runner + state + nodes + tool + prompt锛夈€?2. `agent2/node/schedule_refine.go` 浣滀负 node 灞傜粺涓€闂ㄩ潰锛氬澶栨毚闇?`ScheduleRefineState`銆乣ScheduleRefineGraphRunInput`銆乣NewScheduleRefineState`銆乣RunScheduleRefineGraph`銆乣FinalHardCheckPassed`銆?3. `agent2/node/schedule_refine_tool.go` 淇濈暀鍙屾枃浠舵牸灞€涓殑宸ュ叿鎵胯浇浣嶏紝褰撳墠宸ュ叿鍏蜂綋瀹炵幇涓嬫矇鍦?`agent2/node/schedule_refine_impl/tool.go`銆?4. `agent2/graph/schedule.go` 宸叉柊澧?`RunScheduleRefineGraph`锛岄€氳繃 node 闂ㄩ潰杩涘叆杩炵画寰皟鍥俱€?5. `service/agentsvc/agent_schedule_refine.go` 宸插垏娴佸埌 agent2锛氱姸鎬佸垵濮嬪寲銆佸浘鎵ц銆佺粓瀹¢€氳繃鍒ゅ畾鍧囦笉鍐嶄緷璧栨棫 `agent/schedulerefine`銆?6. 鍏煎璇存槑锛氭棫 `agent/schedulerefine` 鐩綍鏆備繚鐣欙紝浣滀负杩佺Щ鏈熷苟琛屽疄鐜帮紝褰撳墠鐢熶骇鍏ュ彛宸叉寚鍚?agent2 閾捐矾銆?
## 9. 2026-03-26 schedule_refine 缁撴瀯淇锛堣ˉ璁帮級
1. 绉婚櫎 `agent2/node/schedule_refine_impl` 鏍圭洰褰曞疄鐜帮紝鏀逛负鏀惧埌 `agent2/node/schedule_refine_impl`銆?2. `agent2/node/schedule_refine.go` 缁х画淇濈暀缁熶竴闂ㄩ潰鑱岃矗锛岄伩鍏?service/graph 鐩存帴渚濊禆缁嗚妭瀹炵幇銆?3. `agent2/node/schedule_refine_tool.go` 淇濈暀鍙屾枃浠舵牸灞€锛屽伐鍏峰疄鐜颁綅缃敼涓?`agent2/node/schedule_refine_impl/tool.go`銆?4. `agent2/graph/schedule.go` 娉ㄩ噴宸叉竻鐞嗕贡鐮侊紝graph 浠呰礋璐f牎楠屼笌缂栨帓銆?5. `service/agentsvc/agent_schedule_refine.go` 鍏ュ彛淇濇寔涓嶅彉锛屼粛瀹屽叏涓庢棫 `backend/agent/*` 瑙h€︺€?
1. `agent2` 的公共层要尽量保持“低耦合、强注释、易迁移”。
2. 新技能开发时,优先复用这里已有的公共能力,而不是直接复制旧技能代码。
3. 如果发现某段逻辑已经出现第二份实现,应优先评估抽公共层,而不是继续写第三份。
4. 后续只要扩展通用能力,必须同步更新本文档,否则视为迁移或重构未完成。