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. 后续只要扩展通用能力,必须同步更新本文档,否则视为迁移或重构未完成。

View File

@@ -7,21 +7,20 @@ import (
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/agent/scheduleplan"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
agentshared "github.com/LoveLosita/smartflow/backend/agent2/shared"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond"
)
// saveSchedulePlanPreview 把排程结果以结构化 JSON 快照写入 Redis
// saveSchedulePlanPreview 鎶婃帓绋嬬粨鏋滀互缁撴瀯鍖?JSON 蹇収鍐欏叆 Redis銆?
//
// 职责边界:
// 1. 负责把 finalState 中的 summary + candidate_plans 收敛为缓存 DTO
// 2. 负责以“失败不阻断聊天主链路”的策略执行写入;
// 3. 不负责 SSE 返回协议,不负责数据库落库。
func (s *AgentService) saveSchedulePlanPreview(ctx context.Context, userID int, chatID string, finalState *scheduleplan.SchedulePlanState) {
// 1. 基础前置校验state 为空时直接返回,避免写入半成品快照。
// 鑱岃矗杈圭晫锛?
// 1. 璐熻矗鎶?finalState 涓殑 summary + candidate_plans 鏀舵暃涓虹紦瀛?DTO锛?
// 2. 璐熻矗浠モ€滃け璐ヤ笉闃绘柇鑱婂ぉ涓婚摼璺€濈殑绛栫暐鎵ц鍐欏叆锛?
// 3. 涓嶈礋璐?SSE 杩斿洖鍗忚锛屼笉璐熻矗鏁版嵁搴撹惤搴撱€?
func (s *AgentService) saveSchedulePlanPreview(ctx context.Context, userID int, chatID string, finalState *agentmodel.SchedulePlanState) {
// 1. 鍩虹鍓嶇疆鏍¢獙锛歴tate 涓虹┖鏃剁洿鎺ヨ繑鍥烇紝閬垮厤鍐欏叆鍗婃垚鍝佸揩鐓с€?
if s == nil || finalState == nil {
return
}
@@ -30,10 +29,10 @@ func (s *AgentService) saveSchedulePlanPreview(ctx context.Context, userID int,
return
}
// 2. 组装缓存快照:
// 2.1 summary 优先取 final summary,空值时使用统一兜底文案;
// 2.2 candidate_plans 做切片拷贝,避免后续引用共享导致意外覆盖;
// 2.3 generated_at 用于前端判断“当前预览的新鲜度”。
// 2. 缁勮缂撳瓨蹇収锛?
// 2.1 summary 浼樺厛鍙?final summary锛岀┖鍊兼椂浣跨敤缁熶竴鍏滃簳鏂囨锛?
// 2.2 candidate_plans 鍋氬垏鐗囨嫹璐濓紝閬垮厤鍚庣画寮曠敤鍏变韩瀵艰嚧鎰忓瑕嗙洊锛?
// 2.3 generated_at 鐢ㄤ簬鍓嶇鍒ゆ柇鈥滃綋鍓嶉瑙堢殑鏂伴矞搴︹€濄€?
summary := strings.TrimSpace(finalState.FinalSummary)
if summary == "" {
summary = "排程流程已完成,但未生成结果摘要。"
@@ -50,35 +49,35 @@ func (s *AgentService) saveSchedulePlanPreview(ctx context.Context, userID int,
GeneratedAt: time.Now(),
}
// 3. 调用目的:先写 Redis 预览,保证前端查询接口能快速读取结构化结果。
// 3.1 Redis 是“快路径”;失败只记录日志,不中断主链路;
// 3.2 失败兜底由后续 MySQL 快照承接。
// 3. 璋冪敤鐩殑锛氬厛鍐?Redis 棰勮锛屼繚璇佸墠绔煡璇㈡帴鍙h兘蹇€熻鍙栫粨鏋勫寲缁撴灉銆?
// 3.1 Redis 鏄€滃揩璺緞鈥濓紱澶辫触鍙褰曟棩蹇楋紝涓嶄腑鏂富閾捐矾锛?
// 3.2 澶辫触鍏滃簳鐢卞悗缁?MySQL 蹇収鎵挎帴銆?
if s.cacheDAO != nil {
if err := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedChatID, preview); err != nil {
log.Printf("写入排程预览缓存失败 chat_id=%s: %v", normalizedChatID, err)
log.Printf("鍐欏叆鎺掔▼棰勮缂撳瓨澶辫触 chat_id=%s: %v", normalizedChatID, err)
}
}
// 4. 调用目的:同步写 MySQL 状态快照,保证 Redis 失效后仍可连续微调。
// 4.1 这里采用“同步写库”而不是 outbox因为下一轮微调要强实时读取
// 4.2 快照写入失败只打日志,不阻断本轮用户回复,避免体验抖动;
// 4.3 revision 自增由 DAO upsert 冲突更新负责。
// 4. 璋冪敤鐩殑锛氬悓姝ュ啓 MySQL 鐘舵€佸揩鐓э紝淇濊瘉 Redis 澶辨晥鍚庝粛鍙繛缁井璋冦€?
// 4.1 杩欓噷閲囩敤鈥滃悓姝ュ啓搴撯€濊€屼笉鏄?outbox锛氬洜涓轰笅涓€杞井璋冭寮哄疄鏃惰鍙栵紱
// 4.2 蹇収鍐欏叆澶辫触鍙墦鏃ュ織锛屼笉闃绘柇鏈疆鐢ㄦ埛鍥炲锛岄伩鍏嶄綋楠屾姈鍔紱
// 4.3 revision 鑷鐢?DAO 鐨?upsert 鍐茬獊鏇存柊璐熻矗銆?
if s.repo != nil {
snapshot := buildSchedulePlanSnapshotFromState(userID, normalizedChatID, finalState)
if err := s.repo.UpsertScheduleStateSnapshot(ctx, snapshot); err != nil {
log.Printf("写入排程状态快照失败 chat_id=%s: %v", normalizedChatID, err)
log.Printf("鍐欏叆鎺掔▼鐘舵€佸揩鐓уけ璐?chat_id=%s: %v", normalizedChatID, err)
}
}
}
// saveSchedulePlanPreviewAgent2 agent2 schedule_plan 结果写入 Redis 预览与 MySQL 快照。
// saveSchedulePlanPreviewAgent2 鎶?agent2 鐨?schedule_plan 缁撴灉鍐欏叆 Redis 棰勮涓?MySQL 蹇収銆?
//
// 职责边界:
// 1. 负责承接“新 agent2 首次排程链路”的最终状态;
// 2. 负责沿用现有预览缓存/状态快照协议,保证查询接口与 refine 读取逻辑不需要跟着重写;
// 3. 不负责 refine 状态转换refine 仍继续走旧链路的 saveSchedulePlanPreview
// 鑱岃矗杈圭晫锛?
// 1. 璐熻矗鎵挎帴鈥滄柊 agent2 棣栨鎺掔▼閾捐矾鈥濈殑鏈€缁堢姸鎬侊紱
// 2. 璐熻矗娌跨敤鐜版湁棰勮缂撳瓨/鐘舵€佸揩鐓у崗璁紝淇濊瘉鏌ヨ鎺ュ彛涓?refine 璇诲彇閫昏緫涓嶉渶瑕佽窡鐫€閲嶅啓锛?
// 3. 涓嶈礋璐?refine 鐘舵€佽浆鎹紝refine 浠嶇户缁蛋鏃ч摼璺殑 saveSchedulePlanPreview銆?
func (s *AgentService) saveSchedulePlanPreviewAgent2(ctx context.Context, userID int, chatID string, finalState *agentmodel.SchedulePlanState) {
// 1. 基础前置校验state 为空时直接返回,避免写入半成品快照。
// 1. 鍩虹鍓嶇疆鏍¢獙锛歴tate 涓虹┖鏃剁洿鎺ヨ繑鍥烇紝閬垮厤鍐欏叆鍗婃垚鍝佸揩鐓с€?
if s == nil || finalState == nil {
return
}
@@ -87,10 +86,10 @@ func (s *AgentService) saveSchedulePlanPreviewAgent2(ctx context.Context, userID
return
}
// 2. 组装缓存快照。
// 2.1 summary 优先取 final summary,空值时使用统一兜底文案;
// 2.2 candidate_plans / hybrid_entries / allocated_items 统一深拷贝,避免缓存与 graph state 共用底层切片;
// 2.3 generated_at 用于前端判断“当前预览是否为最新方案”。
// 2. 缁勮缂撳瓨蹇収銆?
// 2.1 summary 浼樺厛鍙?final summary锛岀┖鍊兼椂浣跨敤缁熶竴鍏滃簳鏂囨锛?
// 2.2 candidate_plans / hybrid_entries / allocated_items 缁熶竴娣辨嫹璐濓紝閬垮厤缂撳瓨涓?graph state 鍏辩敤搴曞眰鍒囩墖锛?
// 2.3 generated_at 鐢ㄤ簬鍓嶇鍒ゆ柇鈥滃綋鍓嶉瑙堟槸鍚︿负鏈€鏂版柟妗堚€濄€?
summary := strings.TrimSpace(finalState.FinalSummary)
if summary == "" {
summary = "排程流程已完成,但未生成结果摘要。"
@@ -107,34 +106,34 @@ func (s *AgentService) saveSchedulePlanPreviewAgent2(ctx context.Context, userID
GeneratedAt: time.Now(),
}
// 3. 先写 Redis 预览,保证前端查询接口能立即读取结构化结果。
// 3.1 Redis 是“快路径”;
// 3.2 失败只记录日志,不中断聊天主链路。
// 3. 鍏堝啓 Redis 棰勮锛屼繚璇佸墠绔煡璇㈡帴鍙h兘绔嬪嵆璇诲彇缁撴瀯鍖栫粨鏋溿€?
// 3.1 Redis 鏄€滃揩璺緞鈥濓紱
// 3.2 澶辫触鍙褰曟棩蹇楋紝涓嶄腑鏂亰澶╀富閾捐矾銆?
if s.cacheDAO != nil {
if err := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedChatID, preview); err != nil {
log.Printf("写入排程预览缓存失败 chat_id=%s: %v", normalizedChatID, err)
log.Printf("鍐欏叆鎺掔▼棰勮缂撳瓨澶辫触 chat_id=%s: %v", normalizedChatID, err)
}
}
// 4. 同步写 MySQL 快照,保证 Redis 失效后仍能恢复预览与连续微调上下文。
// 4.1 这里继续保持“同步写库”策略,因为下一轮微调对快照读取是强实时依赖;
// 4.2 写库失败只打日志,不阻断本轮给用户的文本回复。
// 4. 鍚屾鍐?MySQL 蹇収锛屼繚璇?Redis 澶辨晥鍚庝粛鑳芥仮澶嶉瑙堜笌杩炵画寰皟涓婁笅鏂囥€?
// 4.1 杩欓噷缁х画淇濇寔鈥滃悓姝ュ啓搴撯€濈瓥鐣ワ紝鍥犱负涓嬩竴杞井璋冨蹇収璇诲彇鏄己瀹炴椂渚濊禆锛?
// 4.2 鍐欏簱澶辫触鍙墦鏃ュ織锛屼笉闃绘柇鏈疆缁欑敤鎴风殑鏂囨湰鍥炲銆?
if s.repo != nil {
snapshot := buildSchedulePlanSnapshotFromAgent2State(userID, normalizedChatID, finalState)
if err := s.repo.UpsertScheduleStateSnapshot(ctx, snapshot); err != nil {
log.Printf("写入排程状态快照失败 chat_id=%s: %v", normalizedChatID, err)
log.Printf("鍐欏叆鎺掔▼鐘舵€佸揩鐓уけ璐?chat_id=%s: %v", normalizedChatID, err)
}
}
}
// GetSchedulePlanPreview conversation_id 读取结构化排程预览。
// GetSchedulePlanPreview 鎸?conversation_id 璇诲彇缁撴瀯鍖栨帓绋嬮瑙堛€?
//
// 职责边界:
// 1. 负责参数归一化、缓存读取与会话归属校验;
// 2. 负责把缓存 DTO 转成 API 响应 DTO
// 3. 不负责触发排程,不负责补算缓存。
// 鑱岃矗杈圭晫锛?
// 1. 璐熻矗鍙傛暟褰掍竴鍖栥€佺紦瀛樿鍙栦笌浼氳瘽褰掑睘鏍¢獙锛?
// 2. 璐熻矗鎶婄紦瀛?DTO 杞垚 API 鍝嶅簲 DTO锛?
// 3. 涓嶈礋璐hЕ鍙戞帓绋嬶紝涓嶈礋璐hˉ绠楃紦瀛樸€?
func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, chatID string) (*model.GetSchedulePlanPreviewResponse, error) {
// 1. 参数校验conversation_id 为空直接返回参数错误,避免无效 Redis 请求。
// 1. 鍙傛暟鏍¢獙锛歝onversation_id 涓虹┖鐩存帴杩斿洖鍙傛暟閿欒锛岄伩鍏嶆棤鏁?Redis 璇锋眰銆?
normalizedChatID := strings.TrimSpace(chatID)
if normalizedChatID == "" {
return nil, respond.MissingParam
@@ -143,10 +142,10 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c
return nil, errors.New("agent service is not initialized")
}
// 2. 查询缓存并校验归属:
// 2.1 缓存未命中:统一返回“预览不存在/已过期”;
// 2.2 命中但 user_id 不一致:按未命中处理,避免泄露他人会话信息;
// 2.3 失败兜底:缓存读异常直接上抛,由 API 层统一错误处理。
// 2. 鏌ヨ缂撳瓨骞舵牎楠屽綊灞烇細
// 2.1 缂撳瓨鏈懡涓細缁熶竴杩斿洖鈥滈瑙堜笉瀛樺湪/宸茶繃鏈熲€濓紱
// 2.2 鍛戒腑浣?user_id 涓嶄竴鑷达細鎸夋湭鍛戒腑澶勭悊锛岄伩鍏嶆硠闇蹭粬浜轰細璇濅俊鎭紱
// 2.3 澶辫触鍏滃簳锛氱紦瀛樿寮傚父鐩存帴涓婃姏锛岀敱 API 灞傜粺涓€閿欒澶勭悊銆?
if s.cacheDAO != nil {
preview, err := s.cacheDAO.GetSchedulePlanPreviewFromCache(ctx, userID, normalizedChatID)
if err != nil {
@@ -170,10 +169,10 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c
}
}
// 3. Redis 未命中时回落 MySQL 快照:
// 3.1 读取成功后直接返回,避免用户看到“预览不存在”的假阴性;
// 3.2 若本次命中 DB 且缓存可用,则顺手回填 Redis提升后续命中率
// 3.3 DB 也未命中时再返回 not found
// 3. Redis 鏈懡涓椂鍥炶惤 MySQL 蹇収锛?
// 3.1 璇诲彇鎴愬姛鍚庣洿鎺ヨ繑鍥烇紝閬垮厤鐢ㄦ埛鐪嬪埌鈥滈瑙堜笉瀛樺湪鈥濈殑鍋囬槾鎬э紱
// 3.2 鑻ユ湰娆″懡涓?DB 涓旂紦瀛樺彲鐢紝鍒欓『鎵嬪洖濉?Redis锛屾彁鍗囧悗缁懡涓巼锛?
// 3.3 DB 涔熸湭鍛戒腑鏃跺啀杩斿洖 not found銆?
if s.repo != nil {
snapshot, err := s.repo.GetScheduleStateSnapshot(ctx, userID, normalizedChatID)
if err != nil {
@@ -184,7 +183,7 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c
if s.cacheDAO != nil {
cachePreview := snapshotToSchedulePlanPreviewCache(snapshot)
if setErr := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedChatID, cachePreview); setErr != nil {
log.Printf("回填排程预览缓存失败 chat_id=%s: %v", normalizedChatID, setErr)
log.Printf("鍥炲~鎺掔▼棰勮缂撳瓨澶辫触 chat_id=%s: %v", normalizedChatID, setErr)
}
}
return response, nil
@@ -193,28 +192,28 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c
return nil, respond.SchedulePlanPreviewNotFound
}
// cloneWeekSchedules 对周视图排程结果做深拷贝,避免切片引用共享。
// cloneWeekSchedules 瀵瑰懆瑙嗗浘鎺掔▼缁撴灉鍋氭繁鎷疯礉锛岄伩鍏嶅垏鐗囧紩鐢ㄥ叡浜€?
func cloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule {
return agentshared.CloneWeekSchedules(src)
}
// cloneHybridEntries 深拷贝混合条目切片,避免缓存/状态之间相互污染。
// cloneHybridEntries 娣辨嫹璐濇贩鍚堟潯鐩垏鐗囷紝閬垮厤缂撳瓨/鐘舵€佷箣闂寸浉浜掓薄鏌撱€?
func cloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry {
return agentshared.CloneHybridEntries(src)
}
// cloneTaskClassItems 深拷贝任务块切片(包含指针字段),避免跨请求引用共享。
// cloneTaskClassItems 娣辨嫹璐濅换鍔″潡鍒囩墖锛堝寘鍚寚閽堝瓧娈碉級锛岄伩鍏嶈法璇锋眰寮曠敤鍏变韩銆?
func cloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem {
return agentshared.CloneTaskClassItems(src)
}
// buildSchedulePlanSnapshotFromState graph 运行结果映射成可持久化快照 DTO
// buildSchedulePlanSnapshotFromState 鎶?graph 杩愯缁撴灉鏄犲皠鎴愬彲鎸佷箙鍖栧揩鐓?DTO銆?
//
// 职责边界:
// 1. 负责字段映射与深拷贝,避免跨层共享可变切片;
// 2. 负责补齐 state_version 默认值;
// 3. 不负责数据库写入(写入由 DAO 承担)。
func buildSchedulePlanSnapshotFromState(userID int, conversationID string, st *scheduleplan.SchedulePlanState) *model.SchedulePlanStateSnapshot {
// 鑱岃矗杈圭晫锛?
// 1. 璐熻矗瀛楁鏄犲皠涓庢繁鎷疯礉锛岄伩鍏嶈法灞傚叡浜彲鍙樺垏鐗囷紱
// 2. 璐熻矗琛ラ綈 state_version 榛樿鍊硷紱
// 3. 涓嶈礋璐f暟鎹簱鍐欏叆锛堝啓鍏ョ敱 DAO 鎵挎媴锛夈€?
func buildSchedulePlanSnapshotFromState(userID int, conversationID string, st *agentmodel.SchedulePlanState) *model.SchedulePlanStateSnapshot {
if st == nil {
return nil
}
@@ -237,12 +236,12 @@ func buildSchedulePlanSnapshotFromState(userID int, conversationID string, st *s
}
}
// buildSchedulePlanSnapshotFromAgent2State agent2 的排程状态映射成可持久化快照 DTO
// buildSchedulePlanSnapshotFromAgent2State 鎶?agent2 鐨勬帓绋嬬姸鎬佹槧灏勬垚鍙寔涔呭寲蹇収 DTO銆?
//
// 调用目的:
// 1. 这轮只迁移 schedule_plan,不动 refine
// 2. 因此 preview/快照协议继续复用老结构但要补一个“agent2 state -> snapshot DTO”的映射层;
// 3. 这样可以做到:计划创建链路切到 agent2,而 refine / 预览查询链路暂时无需大改。
// 璋冪敤鐩殑锛?
// 1. 杩欒疆鍙縼绉?schedule_plan锛屼笉鍔?refine锛?
// 2. 鍥犳 preview/蹇収鍗忚缁х画澶嶇敤鑰佺粨鏋勶紝浣嗚琛ヤ竴涓€渁gent2 state -> snapshot DTO鈥濈殑鏄犲皠灞傦紱
// 3. 杩欐牱鍙互鍋氬埌锛氳鍒掑垱寤洪摼璺垏鍒?agent2锛岃€?refine / 棰勮鏌ヨ閾捐矾鏆傛椂鏃犻渶澶ф敼銆?
func buildSchedulePlanSnapshotFromAgent2State(userID int, conversationID string, st *agentmodel.SchedulePlanState) *model.SchedulePlanStateSnapshot {
if st == nil {
return nil
@@ -266,7 +265,7 @@ func buildSchedulePlanSnapshotFromAgent2State(userID int, conversationID string,
}
}
// snapshotToSchedulePlanPreviewCache MySQL 快照转换为 Redis 预览缓存结构。
// snapshotToSchedulePlanPreviewCache 鎶?MySQL 蹇収杞崲涓?Redis 棰勮缂撳瓨缁撴瀯銆?
func snapshotToSchedulePlanPreviewCache(snapshot *model.SchedulePlanStateSnapshot) *model.SchedulePlanPreviewCache {
if snapshot == nil {
return nil
@@ -292,7 +291,7 @@ func snapshotToSchedulePlanPreviewCache(snapshot *model.SchedulePlanStateSnapsho
}
}
// snapshotToSchedulePlanPreviewResponse MySQL 快照转换为查询接口响应。
// snapshotToSchedulePlanPreviewResponse 鎶?MySQL 蹇収杞崲涓烘煡璇㈡帴鍙e搷搴斻€?
func snapshotToSchedulePlanPreviewResponse(snapshot *model.SchedulePlanStateSnapshot) *model.GetSchedulePlanPreviewResponse {
if snapshot == nil {
return nil

View File

@@ -6,20 +6,21 @@ import (
"log"
"strings"
"github.com/LoveLosita/smartflow/backend/agent/scheduleplan"
"github.com/LoveLosita/smartflow/backend/agent/schedulerefine"
agentgraph "github.com/LoveLosita/smartflow/backend/agent2/graph"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
agentnode "github.com/LoveLosita/smartflow/backend/agent2/node"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond"
"github.com/cloudwego/eino-ext/components/model/ark"
)
// runScheduleRefineFlow 执行“连续对话微调排程”分支。
// runScheduleRefineFlow 鎵ц鈥滆繛缁璇濆井璋冩帓绋嬧€濆垎鏀€?
//
// 职责边界:
// 1. 负责读取“上一版排程预览快照”(优先 Redis缺失再回源 MySQL
// 2. 负责调用独立 schedulerefine 图链路完成本轮微调;
// 3. 负责把微调结果回写预览缓存与状态快照,供后续继续微调;
// 4. 不负责聊天消息持久化(消息持久化由 AgentChat 主链路统一处理)。
// 鑱岃矗杈圭晫锛?
// 1. 璐熻矗璇诲彇鈥滀笂涓€鐗堟帓绋嬮瑙堝揩鐓р€濓紙浼樺厛 Redis锛岀己澶卞啀鍥炴簮 MySQL锛夛紱
// 2. 璐熻矗璋冪敤鐙珛 schedulerefine 鍥鹃摼璺畬鎴愭湰杞井璋冿紱
// 3. 璐熻矗鎶婂井璋冪粨鏋滃洖鍐欓瑙堢紦瀛樹笌鐘舵€佸揩鐓э紝渚涘悗缁户缁井璋冿紱
// 4. 涓嶈礋璐h亰澶╂秷鎭寔涔呭寲锛堟秷鎭寔涔呭寲鐢?AgentChat 涓婚摼璺粺涓€澶勭悊锛夈€?
func (s *AgentService) runScheduleRefineFlow(
ctx context.Context,
selectedModel *ark.ChatModel,
@@ -34,24 +35,24 @@ func (s *AgentService) runScheduleRefineFlow(
_ = outChan
_ = modelName
// 1. 依赖预检:模型为空时无法执行任何节点,直接失败避免空指针。
// 1. 渚濊禆棰勬锛氭ā鍨嬩负绌烘椂鏃犳硶鎵ц浠讳綍鑺傜偣锛岀洿鎺ュけ璐ラ伩鍏嶇┖鎸囬拡銆?
if selectedModel == nil {
return "", errors.New("schedule refine model is nil")
}
emitStage("schedule_refine.context.loading", "正在加载上一版排程上下文。")
// 2. 先查 Redis 预览快照,保证热路径低延迟。
// 2.1 如果 Redis 未命中,再回源 MySQL 快照兜底;
// 2.2 如果两者都没有,说明当前会话没有可微调基础,直接返回业务错误。
// 2. 鍏堟煡 Redis 棰勮蹇収锛屼繚璇佺儹璺緞浣庡欢杩熴€?
// 2.1 濡傛灉 Redis 鏈懡涓紝鍐嶅洖婧?MySQL 蹇収鍏滃簳锛?
// 2.2 濡傛灉涓よ€呴兘娌℃湁锛岃鏄庡綋鍓嶄細璇濇病鏈夊彲寰皟鍩虹锛岀洿鎺ヨ繑鍥炰笟鍔¢敊璇€?
preview := s.loadSchedulePreviewContext(ctx, userID, chatID)
if preview == nil {
return "", respond.SchedulePlanPreviewNotFound
}
// 3. 初始化微调状态并运行独立图。
state := schedulerefine.NewScheduleRefineState(traceID, userID, chatID, userMessage, preview)
finalState, runErr := schedulerefine.RunScheduleRefineGraph(ctx, schedulerefine.ScheduleRefineGraphRunInput{
// 3. 鍒濆鍖栧井璋冪姸鎬佸苟杩愯鐙珛鍥俱€?
state := agentnode.NewScheduleRefineState(traceID, userID, chatID, userMessage, preview)
finalState, runErr := agentgraph.RunScheduleRefineGraph(ctx, agentnode.ScheduleRefineGraphRunInput{
Model: selectedModel,
State: state,
EmitStage: emitStage,
@@ -63,12 +64,12 @@ func (s *AgentService) runScheduleRefineFlow(
return "", errors.New("schedule refine graph returned nil state")
}
// 4. 调用目的:
// 4.1 saveSchedulePlanPreview 目前是“预览缓存 + MySQL 快照”的统一写入口;
// 4.2 这里把 refine state 映射为 scheduleplan state,复用已有落盘链路;
// 4.3 但若是“独立复合分支已出站、终审仍失败”,则不覆盖上一版预览,避免外部误以为新方案已验证通过。
// 4. 璋冪敤鐩殑锛?
// 4.1 saveSchedulePlanPreview 鐩墠鏄€滈瑙堢紦瀛?+ MySQL 蹇収鈥濈殑缁熶竴鍐欏叆鍙o紱
// 4.2 杩欓噷鎶?refine state 鏄犲皠涓?scheduleplan state锛屽鐢ㄥ凡鏈夎惤鐩橀摼璺紱
// 4.3 浣嗚嫢鏄€滅嫭绔嬪鍚堝垎鏀凡鍑虹珯銆佺粓瀹′粛澶辫触鈥濓紝鍒欎笉瑕嗙洊涓婁竴鐗堥瑙堬紝閬垮厤澶栭儴璇互涓烘柊鏂规宸查獙璇侀€氳繃銆?
if shouldPersistScheduleRefinePreview(finalState) {
s.saveSchedulePlanPreview(ctx, userID, chatID, convertRefineStateToPlanState(finalState))
s.saveSchedulePlanPreviewAgent2(ctx, userID, chatID, convertRefineStateToPlanState(finalState))
} else {
emitStage("schedule_refine.preview.skipped", "复合分支终审未通过,本轮结果不覆盖上一版预览。")
}
@@ -80,13 +81,13 @@ func (s *AgentService) runScheduleRefineFlow(
return reply, nil
}
// loadSchedulePreviewContext 读取“可用于连续微调”的排程上下文快照。
// loadSchedulePreviewContext 璇诲彇鈥滃彲鐢ㄤ簬杩炵画寰皟鈥濈殑鎺掔▼涓婁笅鏂囧揩鐓с€?
//
// 步骤化说明:
// 1. 先查 Redis:命中则直接返回,时延最小;
// 2. Redis miss 再查 MySQL:保证缓存过期后仍可继续微调;
// 3. MySQL 命中且 Redis 可用,顺便回填 Redis提升后续命中率
// 4. 任一步失败仅打日志,不 panic由上层根据返回 nil 做统一处理。
// 姝ラ鍖栬鏄庯細
// 1. 鍏堟煡 Redis锛氬懡涓垯鐩存帴杩斿洖锛屾椂寤舵渶灏忥紱
// 2. Redis miss 鍐嶆煡 MySQL锛氫繚璇佺紦瀛樿繃鏈熷悗浠嶅彲缁х画寰皟锛?
// 3. 鑻?MySQL 鍛戒腑涓?Redis 鍙敤锛岄『渚垮洖濉?Redis锛屾彁鍗囧悗缁懡涓巼锛?
// 4. 浠讳竴姝ュけ璐ヤ粎鎵撴棩蹇楋紝涓?panic锛岀敱涓婂眰鏍规嵁杩斿洖 nil 鍋氱粺涓€澶勭悊銆?
func (s *AgentService) loadSchedulePreviewContext(ctx context.Context, userID int, chatID string) *model.SchedulePlanPreviewCache {
normalizedChatID := strings.TrimSpace(chatID)
if normalizedChatID == "" || userID <= 0 {
@@ -96,7 +97,7 @@ func (s *AgentService) loadSchedulePreviewContext(ctx context.Context, userID in
if s.cacheDAO != nil {
preview, err := s.cacheDAO.GetSchedulePlanPreviewFromCache(ctx, userID, normalizedChatID)
if err != nil {
log.Printf("读取排程预览缓存失败 chat_id=%s: %v", normalizedChatID, err)
log.Printf("璇诲彇鎺掔▼棰勮缂撳瓨澶辫触 chat_id=%s: %v", normalizedChatID, err)
} else if preview != nil {
return preview
}
@@ -107,7 +108,7 @@ func (s *AgentService) loadSchedulePreviewContext(ctx context.Context, userID in
}
snapshot, err := s.repo.GetScheduleStateSnapshot(ctx, userID, normalizedChatID)
if err != nil {
log.Printf("读取排程状态快照失败 chat_id=%s: %v", normalizedChatID, err)
log.Printf("璇诲彇鎺掔▼鐘舵€佸揩鐓уけ璐?chat_id=%s: %v", normalizedChatID, err)
return nil
}
if snapshot == nil {
@@ -117,19 +118,19 @@ func (s *AgentService) loadSchedulePreviewContext(ctx context.Context, userID in
preview := snapshotToSchedulePlanPreviewCache(snapshot)
if preview != nil && s.cacheDAO != nil {
if setErr := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedChatID, preview); setErr != nil {
log.Printf("回填排程预览缓存失败 chat_id=%s: %v", normalizedChatID, setErr)
log.Printf("鍥炲~鎺掔▼棰勮缂撳瓨澶辫触 chat_id=%s: %v", normalizedChatID, setErr)
}
}
return preview
}
// convertRefineStateToPlanState schedulerefine 状态映射为 scheduleplan 状态。
// convertRefineStateToPlanState 鎶?schedulerefine 鐘舵€佹槧灏勪负 scheduleplan 鐘舵€併€?
//
// 设计意图:
// 1. 复用现有 saveSchedulePlanPreview 写入链路,减少重复落盘代码;
// 2. 仅映射“预览持久化必须字段”,避免把 refine 运行期临时字段带入存储层;
// 3. 后续如要扩展 refine 专属快照字段,可在该映射处集中演进。
func convertRefineStateToPlanState(st *schedulerefine.ScheduleRefineState) *scheduleplan.SchedulePlanState {
// 璁捐鎰忓浘锛?
// 1. 澶嶇敤鐜版湁 saveSchedulePlanPreview 鍐欏叆閾捐矾锛屽噺灏戦噸澶嶈惤鐩樹唬鐮侊紱
// 2. 浠呮槧灏勨€滈瑙堟寔涔呭寲蹇呴』瀛楁鈥濓紝閬垮厤鎶?refine 杩愯鏈熶复鏃跺瓧娈靛甫鍏ュ瓨鍌ㄥ眰锛?
// 3. 鍚庣画濡傝鎵╁睍 refine 涓撳睘蹇収瀛楁锛屽彲鍦ㄨ鏄犲皠澶勯泦涓紨杩涖€?
func convertRefineStateToPlanState(st *agentnode.ScheduleRefineState) *agentmodel.SchedulePlanState {
if st == nil {
return nil
}
@@ -137,7 +138,7 @@ func convertRefineStateToPlanState(st *schedulerefine.ScheduleRefineState) *sche
if st.Contract.Strategy == "keep" {
adjustmentScope = "small"
}
return &scheduleplan.SchedulePlanState{
return &agentmodel.SchedulePlanState{
TraceID: strings.TrimSpace(st.TraceID),
UserID: st.UserID,
ConversationID: strings.TrimSpace(st.ConversationID),
@@ -157,17 +158,17 @@ func convertRefineStateToPlanState(st *schedulerefine.ScheduleRefineState) *sche
}
}
// shouldPersistScheduleRefinePreview 判断“本轮微调结果是否应覆盖上一版预览”。
// shouldPersistScheduleRefinePreview 鍒ゆ柇鈥滄湰杞井璋冪粨鏋滄槸鍚﹀簲瑕嗙洊涓婁竴鐗堥瑙堚€濄€?
//
// 职责边界:
// 1. 默认沿用原有 refine 持久化策略,保证普通 ReAct 微调链路不受影响;
// 2. 仅当“独立复合分支已直接出站,但终审未通过”时,拒绝覆盖上一版预览;
// 3. 这样可以避免外层把未经验证的复合结果当成新的基线继续滚动微调。
func shouldPersistScheduleRefinePreview(st *schedulerefine.ScheduleRefineState) bool {
// 鑱岃矗杈圭晫锛?
// 1. 榛樿娌跨敤鍘熸湁 refine 鎸佷箙鍖栫瓥鐣ワ紝淇濊瘉鏅€?ReAct 寰皟閾捐矾涓嶅彈褰卞搷锛?
// 2. 浠呭綋鈥滅嫭绔嬪鍚堝垎鏀凡鐩存帴鍑虹珯锛屼絾缁堝鏈€氳繃鈥濇椂锛屾嫆缁濊鐩栦笂涓€鐗堥瑙堬紱
// 3. 杩欐牱鍙互閬垮厤澶栧眰鎶婃湭缁忛獙璇佺殑澶嶅悎缁撴灉褰撴垚鏂扮殑鍩虹嚎缁х画婊氬姩寰皟銆?
func shouldPersistScheduleRefinePreview(st *agentnode.ScheduleRefineState) bool {
if st == nil {
return false
}
if st.CompositeRouteSucceeded && !schedulerefine.FinalHardCheckPassed(st) {
if st.CompositeRouteSucceeded && !agentnode.FinalHardCheckPassed(st) {
return false
}
return true

View File

@@ -845,7 +845,6 @@
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/lodash": "*"
}
@@ -874,7 +873,6 @@
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -1524,15 +1522,13 @@
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash-unified": {
"version": "1.0.3",
@@ -1688,7 +1684,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -1702,7 +1697,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -1730,7 +1724,6 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -1944,7 +1937,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -2048,7 +2040,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.30",
"@vue/compiler-sfc": "3.5.30",

View File

@@ -0,0 +1,132 @@
import http from '@/api/http'
import type { ApiResponse, PlainResponse } from '@/types/api'
import type {
ApplyBatchIntoScheduleItem,
ScheduleDeletePayloadItem,
ScheduleWeekData,
TaskClassCreatePayload,
TaskClassDetail,
TaskClassListItem,
} from '@/types/schedule'
import { extractErrorMessage } from '@/utils/http'
import { createIdempotencyKey } from '@/utils/idempotency'
export async function getWeekSchedule(week?: number) {
try {
const response = await http.get<ApiResponse<ScheduleWeekData[]>>('/schedule/week', {
params: typeof week === 'number' ? { week } : undefined,
})
return response.data.data ?? []
} catch (error) {
throw new Error(extractErrorMessage(error, '周日程加载失败,请稍后重试'))
}
}
export async function getTaskClassList() {
try {
const response = await http.get<ApiResponse<{ task_classes: TaskClassListItem[] }>>('/task-class/list')
return response.data.data?.task_classes ?? []
} catch (error) {
throw new Error(extractErrorMessage(error, '任务类列表加载失败,请稍后重试'))
}
}
export async function getTaskClassDetail(taskClassId: number) {
try {
const response = await http.get<ApiResponse<TaskClassDetail>>('/task-class/get', {
params: {
task_class_id: taskClassId,
},
})
return response.data.data
} catch (error) {
throw new Error(extractErrorMessage(error, '任务类详情加载失败,请稍后重试'))
}
}
export async function createTaskClass(payload: TaskClassCreatePayload, idempotencyKey = createIdempotencyKey('task-class-add')) {
try {
const response = await http.post<PlainResponse>('/task-class/add', payload, {
headers: {
'X-Idempotency-Key': idempotencyKey,
},
})
return response.data
} catch (error) {
throw new Error(extractErrorMessage(error, '创建任务类失败,请稍后重试'))
}
}
export async function smartPlanning(taskClassId: number) {
try {
const response = await http.get<ApiResponse<ScheduleWeekData[]>>('/schedule/smart-planning', {
params: {
task_class_id: taskClassId,
},
})
return response.data.data ?? []
} catch (error) {
throw new Error(extractErrorMessage(error, '智能粗排失败,请稍后重试'))
}
}
export async function smartPlanningMulti(taskClassIds: number[]) {
try {
const response = await http.post<ApiResponse<ScheduleWeekData[]>>('/schedule/smart-planning-multi', {
task_class_ids: taskClassIds,
})
return response.data.data ?? []
} catch (error) {
throw new Error(extractErrorMessage(error, '批量智能粗排失败,请稍后重试'))
}
}
export async function applyBatchIntoSchedule(taskClassId: number, items: ApplyBatchIntoScheduleItem[], idempotencyKey = createIdempotencyKey('schedule-apply')) {
try {
const response = await http.put<PlainResponse>(
'/task-class/apply-batch-into-schedule',
{
task_class_id: taskClassId,
items,
},
{
headers: {
'X-Idempotency-Key': idempotencyKey,
},
},
)
return response.data
} catch (error) {
throw new Error(extractErrorMessage(error, '正式应用日程失败,请稍后重试'))
}
}
export async function deleteScheduleEntries(items: ScheduleDeletePayloadItem[], idempotencyKey = createIdempotencyKey('schedule-delete')) {
try {
const response = await http.delete<PlainResponse>('/schedule/delete', {
data: items,
headers: {
'X-Idempotency-Key': idempotencyKey,
},
})
return response.data
} catch (error) {
throw new Error(extractErrorMessage(error, '解除安排失败,请稍后重试'))
}
}
export async function deleteTaskClassItem(taskItemId: number, idempotencyKey = createIdempotencyKey('task-class-item-delete')) {
try {
const response = await http.delete<PlainResponse>('/task-class/delete-item', {
params: {
task_item_id: taskItemId,
},
headers: {
'X-Idempotency-Key': idempotencyKey,
},
})
return response.data
} catch (error) {
throw new Error(extractErrorMessage(error, '删除任务块失败,请稍后重试'))
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,305 @@
<script setup lang="ts">
import { reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { TaskClassCreatePayload, TaskClassCreateItemPayload } from '@/types/schedule'
const props = defineProps<{
modelValue: boolean
loading?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
submit: [payload: TaskClassCreatePayload]
}>()
const form = reactive({
name: '',
start_date: '',
end_date: '',
total_slots: 8,
allow_filler_course: true,
strategy: 'steady',
excluded_slots: [] as number[],
items: [
{ order: 1, content: '', embedded_time: null },
{ order: 2, content: '', embedded_time: null },
{ order: 3, content: '', embedded_time: null },
] as TaskClassCreateItemPayload[],
})
watch(
() => props.modelValue,
(visible) => {
if (!visible) {
return
}
form.name = ''
form.start_date = ''
form.end_date = ''
form.total_slots = 8
form.allow_filler_course = true
form.strategy = 'steady'
form.excluded_slots = []
form.items = [
{ order: 1, content: '', embedded_time: null },
{ order: 2, content: '', embedded_time: null },
{ order: 3, content: '', embedded_time: null },
]
},
)
function addItem() {
form.items.push({
order: form.items.length + 1,
content: '',
embedded_time: null,
})
}
function removeItem(index: number) {
form.items.splice(index, 1)
form.items.forEach((item, itemIndex) => {
item.order = itemIndex + 1
})
}
function handleSubmit() {
const filteredItems = form.items
.map((item, index) => ({
order: index + 1,
content: item.content.trim(),
embedded_time: null,
}))
.filter((item) => item.content)
if (!form.name.trim()) {
ElMessage.warning('请先填写任务类名称')
return
}
if (!form.start_date || !form.end_date) {
ElMessage.warning('请先补齐开始与结束日期')
return
}
if (filteredItems.length === 0) {
ElMessage.warning('至少添加一个任务块内容')
return
}
emit('submit', {
name: form.name.trim(),
start_date: form.start_date,
end_date: form.end_date,
mode: 'auto',
config: {
total_slots: form.total_slots,
allow_filler_course: form.allow_filler_course,
strategy: form.strategy,
excluded_slots: form.excluded_slots,
},
items: filteredItems,
})
}
</script>
<template>
<el-dialog
:model-value="modelValue"
title="创建任务类"
width="720px"
align-center
class="task-class-dialog"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="task-class-dialog__body">
<div class="task-class-dialog__grid">
<label class="task-class-dialog__field">
<span>任务类名称</span>
<el-input v-model="form.name" placeholder="例如:数据结构复习" maxlength="64" />
</label>
<label class="task-class-dialog__field">
<span>编排策略</span>
<el-select v-model="form.strategy">
<el-option value="steady" label="均衡推进" />
<el-option value="rapid" label="快速冲刺" />
</el-select>
</label>
<label class="task-class-dialog__field">
<span>开始日期</span>
<el-date-picker
v-model="form.start_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择开始日期"
/>
</label>
<label class="task-class-dialog__field">
<span>结束日期</span>
<el-date-picker
v-model="form.end_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择结束日期"
/>
</label>
<label class="task-class-dialog__field">
<span>总节数</span>
<el-input-number v-model="form.total_slots" :min="1" :max="48" />
</label>
<label class="task-class-dialog__field task-class-dialog__field--switch">
<span>允许嵌入水课</span>
<el-switch v-model="form.allow_filler_course" />
</label>
</div>
<div class="task-class-dialog__items">
<div class="task-class-dialog__items-head">
<strong>任务块列表</strong>
<button type="button" class="task-class-dialog__add" @click="addItem">新增任务块</button>
</div>
<div class="task-class-dialog__items-list">
<div v-for="(item, index) in form.items" :key="index" class="task-class-dialog__item">
<span class="task-class-dialog__item-order">{{ index + 1 }}</span>
<el-input v-model="item.content" placeholder="填写任务块内容,例如:树与二叉树" />
<button
type="button"
class="task-class-dialog__item-remove"
:disabled="form.items.length <= 1"
@click="removeItem(index)"
>
删除
</button>
</div>
</div>
</div>
</div>
<template #footer>
<div class="task-class-dialog__footer">
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">创建任务类</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped>
.task-class-dialog__body {
display: grid;
gap: 22px;
}
.task-class-dialog__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.task-class-dialog__field {
display: grid;
gap: 8px;
}
.task-class-dialog__field span,
.task-class-dialog__items-head strong {
color: #1d2940;
font-size: 13px;
font-weight: 700;
}
.task-class-dialog__field--switch {
align-content: start;
}
.task-class-dialog__field :deep(.el-input),
.task-class-dialog__field :deep(.el-select),
.task-class-dialog__field :deep(.el-date-editor) {
width: 100%;
}
.task-class-dialog__items {
display: grid;
gap: 14px;
}
.task-class-dialog__items-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.task-class-dialog__add {
height: 34px;
border: 1px solid rgba(28, 98, 206, 0.18);
border-radius: 12px;
background: #f6f9ff;
color: #1d64d2;
font-size: 12px;
font-weight: 700;
padding: 0 14px;
cursor: pointer;
}
.task-class-dialog__items-list {
display: grid;
gap: 10px;
}
.task-class-dialog__item {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
}
.task-class-dialog__item-order {
width: 36px;
height: 36px;
border-radius: 12px;
background: #eef3f8;
color: #354259;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
.task-class-dialog__item-remove {
height: 36px;
border: 1px solid rgba(185, 42, 29, 0.16);
border-radius: 12px;
background: #fff6f5;
color: #bd3e2f;
font-size: 12px;
font-weight: 700;
padding: 0 12px;
cursor: pointer;
}
.task-class-dialog__item-remove:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.task-class-dialog__footer {
display: flex;
justify-content: flex-end;
}
@media (max-width: 840px) {
.task-class-dialog__grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,419 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { TaskClassDetail, TaskClassListItem } from '@/types/schedule'
const props = defineProps<{
taskClasses: TaskClassListItem[]
loading?: boolean
detailLoading?: boolean
expandedTaskClassId: number | null
expandedTaskClassDetail: TaskClassDetail | null
selectedTaskClassIds: number[]
taskClassMultiSelectMode: boolean
}>()
const emit = defineEmits<{
activate: [taskClassId: number]
toggleMultiMode: []
create: []
deleteItem: [taskItemId: number]
}>()
const taskClassCountLabel = computed(() => `${props.taskClasses.length}`)
function isExpanded(taskClassId: number) {
return props.expandedTaskClassId === taskClassId && !props.taskClassMultiSelectMode
}
function isSelected(taskClassId: number) {
return props.selectedTaskClassIds.includes(taskClassId)
}
function formatEmbeddedTime(value: TaskClassDetail['items'][number]['embedded_time']) {
if (!value?.date) {
return '未安排'
}
const date = new Date(value.date)
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${month}.${day} ${value.section_from}-${value.section_to}`
}
</script>
<template>
<aside class="task-class-sidebar">
<div class="task-class-sidebar__header">
<div class="task-class-sidebar__title-row">
<div class="task-class-sidebar__title-wrap">
<span class="task-class-sidebar__title-icon" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.40039 5.10742L7.99902 1.59961L13.5996 5.10742L7.99902 8.61523L2.40039 5.10742Z" fill="currentColor" />
<path d="M2.40039 8.20312L7.99902 11.7109L13.5996 8.20312" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M2.40039 11.2891L7.99902 14.7969L13.5996 11.2891" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
<strong>任务类别列表</strong>
</div>
<span class="task-class-sidebar__count">{{ taskClassCountLabel }}</span>
</div>
<button type="button" class="task-class-sidebar__mode" @click="emit('toggleMultiMode')">
{{ taskClassMultiSelectMode ? '取消批量' : '批量选择' }}
</button>
</div>
<div v-if="loading" class="task-class-sidebar__skeleton">
<div v-for="index in 4" :key="index" class="task-class-sidebar__skeleton-item" />
</div>
<div v-else class="task-class-sidebar__list">
<article
v-for="taskClass in taskClasses"
:key="taskClass.id"
class="task-class-card"
:class="{
'task-class-card--expanded': isExpanded(taskClass.id),
'task-class-card--selected': isSelected(taskClass.id),
}"
>
<button type="button" class="task-class-card__summary" @click="emit('activate', taskClass.id)">
<span
v-if="taskClassMultiSelectMode"
class="task-class-card__selector"
:class="{ 'task-class-card__selector--active': isSelected(taskClass.id) }"
aria-hidden="true"
/>
<div class="task-class-card__content">
<strong>{{ taskClass.name }}</strong>
<span>{{ taskClass.total_slots }}节课</span>
</div>
<span class="task-class-card__corner" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.22559 3.94922H12.0498V10.7734H10.7998V6.08301L4.3916 12.4912L3.50781 11.6074L9.91602 5.19922H5.22559V3.94922Z" fill="currentColor" />
</svg>
</span>
</button>
<div v-if="isExpanded(taskClass.id)" class="task-class-card__detail">
<div v-if="detailLoading" class="task-class-card__detail-loading">正在载入任务块</div>
<div v-else-if="expandedTaskClassDetail" class="task-class-card__detail-list">
<div
v-for="item in expandedTaskClassDetail.items"
:key="item.order"
class="task-class-card__detail-item"
>
<span class="task-class-card__detail-order">{{ item.order }}</span>
<span class="task-class-card__detail-text">{{ item.content }}</span>
<span
class="task-class-card__detail-status"
:class="{ 'task-class-card__detail-status--arranged': item.embedded_time }"
>
{{ formatEmbeddedTime(item.embedded_time) }}
</span>
<button
type="button"
class="task-class-card__detail-delete"
aria-label="删除任务块"
:disabled="typeof item.id !== 'number'"
@click="typeof item.id === 'number' && emit('deleteItem', item.id)"
>
×
</button>
</div>
</div>
</div>
</article>
<button type="button" class="task-class-sidebar__create" @click="emit('create')">
<span class="task-class-sidebar__create-icon" aria-hidden="true"></span>
<span>点击新建任务类</span>
</button>
</div>
</aside>
</template>
<style scoped>
.task-class-sidebar {
min-width: 0;
min-height: 0;
height: 100%;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
border-right: 1px solid rgba(196, 209, 227, 0.55);
background: linear-gradient(180deg, rgba(251, 253, 255, 0.96), rgba(247, 250, 254, 0.98));
}
.task-class-sidebar__header {
padding: 16px 24px 14px;
border-bottom: 1px solid rgba(214, 223, 238, 0.68);
display: grid;
gap: 12px;
}
.task-class-sidebar__title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.task-class-sidebar__title-wrap {
display: inline-flex;
align-items: center;
gap: 10px;
color: #1f2c42;
}
.task-class-sidebar__title-wrap strong {
font-size: 15px;
font-weight: 800;
}
.task-class-sidebar__title-icon {
width: 16px;
height: 16px;
display: inline-flex;
color: #165fd0;
}
.task-class-sidebar__count {
padding: 5px 12px;
border-radius: 10px;
background: #eef3f9;
color: #75839a;
font-size: 12px;
line-height: 1;
}
.task-class-sidebar__mode {
height: 34px;
border: 1px solid rgba(25, 95, 213, 0.18);
border-radius: 12px;
background: #f6f9ff;
color: #1d64d2;
font-size: 12px;
font-weight: 700;
justify-self: start;
padding: 0 14px;
cursor: pointer;
transition: border-color 0.16s ease, background-color 0.16s ease, color 0.16s ease;
}
.task-class-sidebar__mode:hover {
border-color: rgba(25, 95, 213, 0.34);
background: #edf4ff;
}
.task-class-sidebar__list,
.task-class-sidebar__skeleton {
min-height: 0;
overflow-y: auto;
padding: 24px;
display: grid;
align-content: start;
gap: 14px;
}
.task-class-sidebar__skeleton-item {
height: 120px;
border-radius: 24px;
background: linear-gradient(90deg, rgba(234, 239, 246, 0.9), rgba(248, 251, 255, 1), rgba(234, 239, 246, 0.9));
background-size: 200% 100%;
animation: task-class-skeleton 1.25s linear infinite;
}
.task-class-card {
border-radius: 24px;
border: 1px solid rgba(216, 225, 238, 0.9);
background: linear-gradient(180deg, #fdfefe 0%, #f8fbff 100%);
box-shadow: 0 10px 22px rgba(19, 51, 107, 0.04);
overflow: hidden;
}
.task-class-card--selected {
border-color: rgba(28, 98, 206, 0.28);
box-shadow: 0 14px 24px rgba(22, 95, 208, 0.08);
}
.task-class-card__summary {
width: 100%;
border: none;
background: transparent;
padding: 18px 20px 18px 18px;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px;
align-items: start;
text-align: left;
cursor: pointer;
}
.task-class-card__summary:hover .task-class-card__corner {
background: #edf4ff;
color: #2067d5;
}
.task-class-card__selector {
width: 16px;
height: 16px;
margin-top: 5px;
border-radius: 5px;
border: 1px solid rgba(148, 163, 184, 0.55);
background: #ffffff;
}
.task-class-card__selector--active {
border-color: #1e66d4;
background: #1e66d4;
box-shadow: inset 0 0 0 3px #ffffff;
}
.task-class-card__content {
min-width: 0;
display: grid;
gap: 8px;
}
.task-class-card__content strong {
color: #182741;
font-size: 16px;
line-height: 1.35;
font-weight: 800;
}
.task-class-card__content span {
color: #71819a;
font-size: 13px;
}
.task-class-card__corner {
width: 48px;
height: 48px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(246, 249, 253, 0.9);
color: #1e66d4;
transition: background-color 0.16s ease, color 0.16s ease;
}
.task-class-card__detail {
padding: 0 14px 14px;
}
.task-class-card__detail-loading {
padding: 14px 12px 10px;
color: #7b88a1;
font-size: 13px;
}
.task-class-card__detail-list {
display: grid;
gap: 6px;
}
.task-class-card__detail-item {
min-width: 0;
padding: 10px 12px;
border-radius: 16px;
border: 1px solid rgba(197, 209, 226, 0.8);
background: rgba(255, 255, 255, 0.92);
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto 24px;
gap: 10px;
align-items: center;
}
.task-class-card__detail-order {
color: #17253d;
font-weight: 700;
text-align: center;
}
.task-class-card__detail-text {
min-width: 0;
color: #1e293b;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-class-card__detail-status {
padding: 4px 10px;
border-radius: 999px;
background: #f1f5f9;
color: #74839a;
font-size: 12px;
}
.task-class-card__detail-status--arranged {
background: #dcf4bd;
color: #486a18;
}
.task-class-card__detail-delete {
width: 24px;
height: 24px;
border: none;
border-radius: 999px;
background: #bb3326;
color: #ffffff;
font-size: 16px;
line-height: 1;
cursor: pointer;
}
.task-class-card__detail-delete:disabled {
opacity: 0.32;
cursor: not-allowed;
}
.task-class-sidebar__create {
min-height: 108px;
border: 1px dashed rgba(204, 216, 232, 0.92);
border-radius: 24px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(249, 251, 255, 0.98));
color: #b1bccd;
display: grid;
justify-items: center;
align-content: center;
gap: 10px;
cursor: pointer;
transition: border-color 0.16s ease, background-color 0.16s ease, color 0.16s ease;
}
.task-class-sidebar__create:hover {
border-color: rgba(25, 95, 213, 0.22);
background: #f7fbff;
color: #6d7f99;
}
.task-class-sidebar__create-icon {
width: 24px;
height: 24px;
border-radius: 999px;
border: 1px solid currentColor;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 18px;
line-height: 1;
}
@keyframes task-class-skeleton {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
</style>

View File

@@ -0,0 +1,327 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ScheduleWeekData, ScheduleWeekEvent } from '@/types/schedule'
interface WeekDayHeader {
dayOfWeek: number
label: string
dateLabel: string
}
interface SectionSlot {
order: number
title: string
timeRange: string
}
const props = defineProps<{
weekLabel: string
weekHeaders: WeekDayHeader[]
weekData: ScheduleWeekData | null
scheduleSelectionMode: boolean
selectedScheduleEventIds: number[]
}>()
const emit = defineEmits<{
toggleScheduleEvent: [eventId: number]
}>()
const sectionSlots: SectionSlot[] = [
{ order: 1, title: '1-2', timeRange: '08:00\n09:40' },
{ order: 2, title: '3-4', timeRange: '10:15\n11:55' },
{ order: 3, title: '5-6', timeRange: '14:00\n15:40' },
{ order: 4, title: '7-8', timeRange: '16:15\n17:55' },
{ order: 5, title: '9-10', timeRange: '19:00\n20:40' },
{ order: 6, title: '11-12', timeRange: '20:50\n22:30' },
]
const eventLookup = computed(() => {
const map = new Map<string, ScheduleWeekEvent>()
for (const event of props.weekData?.events ?? []) {
map.set(`${event.day_of_week}-${event.order}`, event)
}
return map
})
function resolveEvent(dayOfWeek: number, order: number) {
return eventLookup.value.get(`${dayOfWeek}-${order}`)
}
function isSelected(eventId: number) {
return props.selectedScheduleEventIds.includes(eventId)
}
function resolveEventTone(event?: ScheduleWeekEvent) {
if (!event || event.type === 'empty') {
return 'empty'
}
if (event.type === 'course') {
return 'course'
}
const toneByOrder: Record<number, string> = {
1: 'amber',
2: 'mint',
3: 'emerald',
4: 'rose',
5: 'violet',
6: 'sky',
}
return toneByOrder[event.order] ?? 'task'
}
function resolveCellTitle(event?: ScheduleWeekEvent) {
if (!event || event.type === 'empty') {
return '空'
}
return event.name
}
function resolveCellMeta(event?: ScheduleWeekEvent) {
if (!event || event.type === 'empty') {
return ''
}
return event.location || '未定'
}
</script>
<template>
<section class="planning-board">
<header class="planning-board__header">
<strong>{{ weekLabel }}</strong>
</header>
<div class="planning-board__grid">
<div class="planning-board__corner" />
<div v-for="header in weekHeaders" :key="header.dayOfWeek" class="planning-board__day-head">
<span>{{ header.label }}</span>
<small>{{ header.dateLabel }}</small>
</div>
<template v-for="slot in sectionSlots" :key="slot.order">
<div class="planning-board__time-cell">
<strong>{{ slot.title }}</strong>
<small>{{ slot.timeRange }}</small>
</div>
<article
v-for="header in weekHeaders"
:key="`${header.dayOfWeek}-${slot.order}`"
class="planning-board__cell"
:class="[
`planning-board__cell--${resolveEventTone(resolveEvent(header.dayOfWeek, slot.order))}`,
{
'planning-board__cell--selectable': scheduleSelectionMode && resolveEvent(header.dayOfWeek, slot.order)?.type !== 'empty',
'planning-board__cell--selected': resolveEvent(header.dayOfWeek, slot.order) && isSelected(resolveEvent(header.dayOfWeek, slot.order)!.id),
},
]"
>
<button
v-if="scheduleSelectionMode && resolveEvent(header.dayOfWeek, slot.order)?.type !== 'empty'"
type="button"
class="planning-board__checkbox"
:class="{ 'planning-board__checkbox--active': isSelected(resolveEvent(header.dayOfWeek, slot.order)!.id) }"
@click="emit('toggleScheduleEvent', resolveEvent(header.dayOfWeek, slot.order)!.id)"
/>
<template v-if="resolveEvent(header.dayOfWeek, slot.order)">
<div class="planning-board__cell-main">
<strong>{{ resolveCellTitle(resolveEvent(header.dayOfWeek, slot.order)) }}</strong>
<span>{{ resolveCellMeta(resolveEvent(header.dayOfWeek, slot.order)) }}</span>
</div>
</template>
</article>
</template>
</div>
</section>
</template>
<style scoped>
.planning-board {
min-width: 0;
min-height: 0;
border-radius: 28px;
border: 1px solid rgba(214, 223, 236, 0.82);
background: linear-gradient(180deg, rgba(252, 253, 255, 0.98), rgba(248, 251, 255, 0.98));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
}
.planning-board__header {
padding: 18px 28px 16px;
border-bottom: 1px solid rgba(221, 229, 240, 0.86);
color: #1f2b42;
font-size: 18px;
}
.planning-board__grid {
min-width: 0;
display: grid;
grid-template-columns: 74px repeat(7, minmax(0, 1fr));
gap: 10px 12px;
padding: 28px 24px 24px;
}
.planning-board__corner {
min-height: 1px;
}
.planning-board__day-head {
display: grid;
justify-items: center;
gap: 4px;
color: #8ca0bd;
}
.planning-board__day-head span {
font-size: 14px;
font-weight: 700;
letter-spacing: 0.08em;
}
.planning-board__day-head small {
font-size: 12px;
}
.planning-board__time-cell {
min-height: 112px;
display: grid;
align-content: center;
justify-items: end;
color: #9aacbf;
padding-right: 8px;
}
.planning-board__time-cell strong {
font-size: 15px;
color: #8da0bc;
}
.planning-board__time-cell small {
white-space: pre-line;
text-align: right;
line-height: 1.35;
font-size: 11px;
}
.planning-board__cell {
position: relative;
min-height: 112px;
border-radius: 22px;
border: 1px solid rgba(228, 234, 243, 0.92);
padding: 18px 14px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
overflow: hidden;
}
.planning-board__cell-main {
display: grid;
gap: 10px;
}
.planning-board__cell-main strong {
color: #7387a3;
font-size: 15px;
line-height: 1.35;
font-weight: 700;
}
.planning-board__cell-main span {
color: #9badc5;
font-size: 12px;
}
.planning-board__cell--course {
background: #acd6f4;
}
.planning-board__cell--course .planning-board__cell-main strong,
.planning-board__cell--course .planning-board__cell-main span {
color: #2576cc;
}
.planning-board__cell--amber {
background: #ffe58b;
}
.planning-board__cell--amber .planning-board__cell-main strong,
.planning-board__cell--amber .planning-board__cell-main span {
color: #7d6917;
}
.planning-board__cell--mint {
background: #d7f7a7;
}
.planning-board__cell--mint .planning-board__cell-main strong,
.planning-board__cell--mint .planning-board__cell-main span,
.planning-board__cell--emerald .planning-board__cell-main strong,
.planning-board__cell--emerald .planning-board__cell-main span {
color: #72a91d;
}
.planning-board__cell--emerald {
background: #d3f3ac;
}
.planning-board__cell--rose {
background: #f6dfe2;
}
.planning-board__cell--rose .planning-board__cell-main strong,
.planning-board__cell--rose .planning-board__cell-main span {
color: #e6696e;
}
.planning-board__cell--violet {
background: #e9dcfb;
}
.planning-board__cell--sky {
background: #d8ecfb;
}
.planning-board__cell--empty {
background: #f8fbff;
}
.planning-board__cell--selectable {
cursor: pointer;
}
.planning-board__cell--selected {
box-shadow: inset 0 0 0 2px rgba(32, 102, 212, 0.52);
}
.planning-board__checkbox {
position: absolute;
top: 16px;
right: 16px;
width: 20px;
height: 20px;
border-radius: 6px;
border: 1px solid rgba(118, 133, 160, 0.46);
background: rgba(255, 255, 255, 0.92);
cursor: pointer;
}
.planning-board__checkbox--active {
border-color: #1e66d4;
background: #1e66d4;
box-shadow: inset 0 0 0 3px #ffffff;
}
@media (max-width: 1560px) {
.planning-board__grid {
grid-template-columns: 64px repeat(7, minmax(118px, 1fr));
}
}
</style>

View File

@@ -4,6 +4,7 @@ import { useAuthStore } from '@/stores/auth'
import AuthView from '@/views/AuthView.vue'
import AssistantView from '@/views/AssistantView.vue'
import DashboardView from '@/views/DashboardView.vue'
import ScheduleView from '@/views/ScheduleView.vue'
const router = createRouter({
history: createWebHistory(),
@@ -36,6 +37,14 @@ const router = createRouter({
requiresAuth: true,
},
},
{
path: '/schedule',
name: 'schedule',
component: ScheduleView,
meta: {
requiresAuth: true,
},
},
],
})

View File

@@ -0,0 +1,99 @@
export type ScheduleEventType = 'course' | 'task' | 'empty'
export interface ScheduleEmbeddedTaskInfo {
id: number
name: string
type: string
}
export interface ScheduleWeekEvent {
id: number
order: number
day_of_week: number
name: string
start_time: string
end_time: string
location: string
type: ScheduleEventType | string
span: number
status?: string
embedded_task_info: ScheduleEmbeddedTaskInfo
}
export interface ScheduleWeekData {
week: number
events: ScheduleWeekEvent[]
}
export interface TaskClassListItem {
id: number
name: string
mode: string
strategy: string
start_date: string
end_date: string
total_slots: number
}
export interface TaskClassEmbeddedTime {
date: string
section_from: number
section_to: number
}
export interface TaskClassDetailItem {
id?: number
order: number
content: string
embedded_time: TaskClassEmbeddedTime | null
}
export interface TaskClassConfig {
total_slots: number
allow_filler_course: boolean
strategy: string
excluded_slots: number[]
}
export interface TaskClassDetail {
name: string
start_date: string
end_date: string
mode: string
config: TaskClassConfig
items: TaskClassDetailItem[]
}
export interface TaskClassCreateItemPayload {
order: number
content: string
embedded_time: TaskClassEmbeddedTime | null
}
export interface TaskClassCreatePayload {
name: string
start_date: string
end_date: string
mode: string
config: TaskClassConfig
items: TaskClassCreateItemPayload[]
}
export interface SmartPlanningMultiPayload {
task_class_ids: number[]
}
export interface ApplyBatchIntoScheduleItem {
task_item_id: number
week: number
day_of_week: number
start_section: number
end_section: number
embed_course_event_id: number
}
export interface ScheduleDeletePayloadItem {
id: number
delete_course: boolean
delete_embedded_task: boolean
}

View File

@@ -1,60 +1,71 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import AssistantPanel from '@/components/dashboard/AssistantPanel.vue'
interface PageSwitchItem {
key: 'dashboard' | 'assistant'
interface SidebarItem {
key: 'home' | 'task' | 'calendar' | 'ai'
label: string
short: string
to: '/dashboard' | '/assistant'
to?: '/dashboard' | '/assistant' | '/schedule'
}
const router = useRouter()
const route = useRoute()
const switchItems: PageSwitchItem[] = [
{
key: 'dashboard',
label: '程',
short: '排',
to: '/dashboard',
},
{
key: 'assistant',
label: '对话',
short: 'AI',
to: '/assistant',
},
const sidebarItems: SidebarItem[] = [
{ key: 'home', label: '总览', short: '总', to: '/dashboard' },
{ key: 'task', label: '任务', short: '任' },
{ key: 'calendar', label: '程', short: '程', to: '/schedule' },
{ key: 'ai', label: '助手', short: 'AI', to: '/assistant' },
]
const activeSwitchKey = computed<PageSwitchItem['key']>(() =>
route.path.startsWith('/assistant') ? 'assistant' : 'dashboard',
)
function handlePageSwitch(targetPath: PageSwitchItem['to']) {
if (route.path !== targetPath) {
router.push(targetPath)
const activeSidebarKey = computed<SidebarItem['key']>(() => {
if (route.path.startsWith('/assistant')) {
return 'ai'
}
if (route.path.startsWith('/schedule')) {
return 'calendar'
}
return 'home'
})
function handleSidebarNavigate(item: SidebarItem) {
// 1. 和首页保持相同行为:已接通路由直接跳转,未接通入口给出明确提示。
// 2. 同路由不重复 push避免产生冗余导航记录。
// 3. 这样可保证两个页面的侧栏交互预期完全一致。
if (item.to) {
if (route.path !== item.to) {
void router.push(item.to)
}
return
}
ElMessage.info(`${item.label} 页面正在开发中`)
}
</script>
<template>
<main class="assistant-view">
<section class="assistant-view__layout">
<aside class="assistant-view__switch-rail glass-panel">
<button
v-for="item in switchItems"
:key="item.key"
type="button"
class="assistant-view__switch-item"
:class="{ 'assistant-view__switch-item--active': activeSwitchKey === item.key }"
@click="handlePageSwitch(item.to)"
>
<span>{{ item.short }}</span>
<small>{{ item.label }}</small>
</button>
<aside class="dashboard-sidebar">
<div class="dashboard-sidebar__brand">S</div>
<nav class="dashboard-sidebar__nav">
<button
v-for="item in sidebarItems"
:key="item.key"
type="button"
class="dashboard-sidebar__nav-item"
:class="{ 'dashboard-sidebar__nav-item--active': item.key === activeSidebarKey }"
@click="handleSidebarNavigate(item)"
>
<span>{{ item.short }}</span>
<small>{{ item.label }}</small>
</button>
</nav>
<button type="button" class="dashboard-sidebar__settings"></button>
</aside>
<AssistantPanel class="assistant-view__panel" view-mode="standalone" />
@@ -65,72 +76,114 @@ function handlePageSwitch(targetPath: PageSwitchItem['to']) {
<style scoped>
.assistant-view {
height: 100vh;
padding: 12px;
padding: 10px;
overflow: hidden;
background: linear-gradient(180deg, #f6f8fb 0%, #eef2f7 100%);
background: #f4f7fb;
}
.assistant-view__layout {
height: calc(100vh - 24px);
height: calc(100vh - 20px);
min-height: 0;
display: grid;
grid-template-columns: minmax(56px, 0.3fr) minmax(0, 6fr);
gap: 12px;
}
.assistant-view__switch-rail {
min-height: 0;
border-radius: 18px;
border: 1px solid rgba(15, 23, 42, 0.08);
background: linear-gradient(180deg, rgba(249, 250, 252, 0.95), rgba(243, 247, 252, 0.98));
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
padding: 14px 7px;
display: grid;
align-content: start;
grid-template-columns: 78px minmax(0, 1fr);
gap: 8px;
align-items: stretch;
}
.assistant-view__switch-item {
border: none;
border-radius: 12px;
background: transparent;
color: #6b7789;
padding: 10px 4px 9px;
cursor: pointer;
.dashboard-sidebar {
height: 100%;
border-radius: 26px;
background: linear-gradient(180deg, #165ca8 0%, #104d8f 100%);
padding: 16px 12px;
display: grid;
justify-items: center;
gap: 4px;
grid-template-rows: auto 1fr auto;
gap: 16px;
}
.assistant-view__switch-item span {
width: 32px;
height: 32px;
border-radius: 10px;
.dashboard-sidebar__brand,
.dashboard-sidebar__settings {
width: 50px;
height: 50px;
border: none;
border-radius: 16px;
background: rgba(255, 255, 255, 0.14);
color: #fff;
font-weight: 800;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.dashboard-sidebar__nav {
display: grid;
gap: 12px;
align-content: start;
}
.dashboard-sidebar__nav-item {
width: 54px;
border: none;
border-radius: 16px;
background: transparent;
color: rgba(255, 255, 255, 0.74);
padding: 10px 8px;
display: grid;
justify-items: center;
gap: 5px;
cursor: pointer;
}
.dashboard-sidebar__nav-item span {
width: 32px;
height: 32px;
border-radius: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.08);
font-weight: 700;
background: rgba(86, 101, 126, 0.1);
}
.assistant-view__switch-item small {
font-size: 11px;
line-height: 1;
.dashboard-sidebar__nav-item small {
font-size: 10px;
}
.assistant-view__switch-item--active {
color: #335fc2;
background: linear-gradient(180deg, rgba(88, 126, 224, 0.16), rgba(88, 126, 224, 0.08));
}
.assistant-view__switch-item--active span {
background: rgba(58, 95, 184, 0.2);
.dashboard-sidebar__nav-item--active {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.assistant-view__panel {
min-width: 0;
min-height: 0;
height: 100%;
border-radius: 18px;
}
@media (max-width: 980px) {
.assistant-view__layout {
height: auto;
grid-template-columns: 1fr;
}
.dashboard-sidebar {
height: auto;
grid-template-columns: auto 1fr auto;
grid-template-rows: none;
align-items: center;
}
.dashboard-sidebar__nav {
grid-auto-flow: column;
justify-content: center;
}
}
@media (max-width: 720px) {
.assistant-view {
height: auto;
padding: 8px;
overflow: visible;
}
}
</style>

View File

@@ -1,9 +1,8 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import AssistantPanel from '@/components/dashboard/AssistantPanel.vue'
import TaskQuadrantCard from '@/components/dashboard/TaskQuadrantCard.vue'
import TodayTimeline from '@/components/dashboard/TodayTimeline.vue'
import { completeTask, createTask, getTasks, undoCompleteTask } from '@/api/task'
@@ -23,12 +22,16 @@ const createTaskLoading = ref(false)
const logoutLoading = ref(false)
const createTaskDialogVisible = ref(false)
const dashboardLayoutRef = ref<HTMLElement | null>(null)
const dashboardMainRef = ref<HTMLElement | null>(null)
const dashboardMainInnerRef = ref<HTMLElement | null>(null)
const dashboardTopbarRef = ref<HTMLElement | null>(null)
const dashboardContentRef = ref<HTMLElement | null>(null)
const dashboardMainScale = ref(1)
const tasks = ref<TaskItem[]>([])
const todayEvents = ref<TodayEvent[]>([])
const sidebarWidth = ref(78)
const assistantWidth = ref(560)
const taskForm = reactive<{
title: string
@@ -44,13 +47,13 @@ interface SidebarItem {
key: 'home' | 'task' | 'calendar' | 'ai'
label: string
short: string
to?: '/dashboard' | '/assistant'
to?: '/dashboard' | '/assistant' | '/schedule'
}
const sidebarItems: SidebarItem[] = [
{ key: 'home', label: '总览', short: '总', to: '/dashboard' },
{ key: 'task', label: '任务', short: '任' },
{ key: 'calendar', label: '日程', short: '程' },
{ key: 'calendar', label: '日程', short: '程', to: '/schedule' },
{ key: 'ai', label: '助手', short: 'AI', to: '/assistant' },
]
@@ -58,6 +61,9 @@ const activeSidebarKey = computed<SidebarItem['key']>(() => {
if (route.path.startsWith('/assistant')) {
return 'ai'
}
if (route.path.startsWith('/schedule')) {
return 'calendar'
}
return 'home'
})
@@ -97,7 +103,9 @@ const pageTitleDate = computed(() => formatHeaderDate(new Date()))
const greetingName = computed(() => authStore.lastUsername || 'SmartFlow 用户')
const layoutStyle = computed(() => ({
'--dashboard-sidebar-width': `${sidebarWidth.value}px`,
'--dashboard-assistant-width': `${assistantWidth.value}px`,
}))
const dashboardMainScaleStyle = computed(() => ({
'--dashboard-main-scale': `${dashboardMainScale.value}`,
}))
const groupedTasks = computed(() => {
@@ -250,41 +258,7 @@ function clampSidebarWidth(nextWidth: number) {
return Math.min(110, Math.max(68, nextWidth))
}
function getAssistantWidthBounds(containerWidth: number, nextSidebarWidth = sidebarWidth.value) {
// 1. 右侧助手区默认按“主区 / 助手区”近似二分来算,贴近用户给出的 DeepSeek 参考布局。
// 2. 只允许在平衡宽度附近做小范围拖拽,避免主区被挤压后卡片内容大面积隐藏。
// 3. 若窗口过窄,则仍保留主区最小可读宽度,优先保证左侧任务与日程信息可见。
const reservedWidth = nextSidebarWidth + 20 + 32
const availableWidth = Math.max(960, containerWidth - reservedWidth)
const balancedWidth = availableWidth / 2
const dragAllowance = Math.min(72, availableWidth * 0.08)
const minWidth = Math.max(440, balancedWidth - dragAllowance)
const maxWidth = Math.max(minWidth, Math.min(760, balancedWidth + dragAllowance))
return {
balancedWidth,
minWidth,
maxWidth,
}
}
function clampAssistantWidth(nextWidth: number, containerWidth = dashboardLayoutRef.value?.getBoundingClientRect().width ?? 1600) {
const { minWidth, maxWidth } = getAssistantWidthBounds(containerWidth)
return Math.min(maxWidth, Math.max(minWidth, nextWidth))
}
function syncAssistantWidthToBalancedSplit() {
const layout = dashboardLayoutRef.value
if (!layout || window.innerWidth <= 1380) {
return
}
const containerWidth = layout.getBoundingClientRect().width
const { balancedWidth } = getAssistantWidthBounds(containerWidth)
assistantWidth.value = clampAssistantWidth(balancedWidth, containerWidth)
}
function startResize(type: 'sidebar' | 'assistant', event: PointerEvent) {
function startResize(type: 'sidebar', event: PointerEvent) {
const layout = dashboardLayoutRef.value
if (!layout || window.innerWidth <= 1380) {
return
@@ -293,26 +267,18 @@ function startResize(type: 'sidebar' | 'assistant', event: PointerEvent) {
const rect = layout.getBoundingClientRect()
const startX = event.clientX
const startSidebarWidth = sidebarWidth.value
const startAssistantWidth = assistantWidth.value
// 1. 拖拽时先记录容器宽度和起始位置,避免每次 move 都重复读布局造成抖动。
// 2. 中间主区域需要保留最小宽度,防止用户把左右面板拖到挤爆内容区
// 2. 主区域需要保留最小宽度,防止侧栏被拖得过宽后正文不可读
// 3. 结束时统一解绑事件,避免指针松开后仍残留拖拽状态。
const handlePointerMove = (moveEvent: PointerEvent) => {
const deltaX = moveEvent.clientX - startX
const splitterTotalWidth = 20
const splitterTotalWidth = 10
const minMainWidth = 560
if (type === 'sidebar') {
const nextSidebarWidth = clampSidebarWidth(startSidebarWidth + deltaX)
const maxSidebarWidth = rect.width - assistantWidth.value - splitterTotalWidth - minMainWidth
sidebarWidth.value = Math.min(nextSidebarWidth, Math.max(68, maxSidebarWidth))
return
}
const maxAssistantWidth = rect.width - sidebarWidth.value - splitterTotalWidth - minMainWidth
const nextAssistantWidth = clampAssistantWidth(startAssistantWidth - deltaX, rect.width)
assistantWidth.value = Math.min(nextAssistantWidth, Math.max(440, maxAssistantWidth))
const nextSidebarWidth = clampSidebarWidth(startSidebarWidth + deltaX)
const maxSidebarWidth = rect.width - splitterTotalWidth - minMainWidth
sidebarWidth.value = Math.min(nextSidebarWidth, Math.max(68, maxSidebarWidth))
}
const stopResize = () => {
@@ -326,16 +292,64 @@ function startResize(type: 'sidebar' | 'assistant', event: PointerEvent) {
window.addEventListener('pointerup', stopResize)
}
function syncDashboardMainScale() {
const main = dashboardMainRef.value
const inner = dashboardMainInnerRef.value
const topbar = dashboardTopbarRef.value
const content = dashboardContentRef.value
if (!main || !inner || !topbar || !content || window.innerWidth <= 980) {
dashboardMainScale.value = 1
return
}
// 1. 先回到 1:1确保拿到未缩放状态下的真实高度。
// 2. 自然高度按“顶部栏高度 + 内容 scrollHeight + 栅格间距”计算,避免被 1fr 约束低估。
// 3. 仅在桌面端启用缩放,小屏仍走原生滚动布局。
dashboardMainScale.value = 1
window.requestAnimationFrame(() => {
const availableHeight = main.clientHeight
const gridGap = 10
const naturalHeight = topbar.getBoundingClientRect().height + content.scrollHeight + gridGap
if (!availableHeight || !naturalHeight) {
dashboardMainScale.value = 1
return
}
// 预留适中安全边距,优先保证底部卡片完整可见,避免再次出现裁切。
const nextScale = Math.min(1, (availableHeight / naturalHeight) * 0.98)
dashboardMainScale.value = Number(nextScale.toFixed(4))
})
}
onMounted(async () => {
await loadDashboardData()
syncAssistantWidthToBalancedSplit()
window.addEventListener('resize', syncAssistantWidthToBalancedSplit)
await nextTick()
syncDashboardMainScale()
window.addEventListener('resize', syncDashboardMainScale)
})
onBeforeUnmount(() => {
document.body.classList.remove('dashboard-resizing')
window.removeEventListener('resize', syncAssistantWidthToBalancedSplit)
window.removeEventListener('resize', syncDashboardMainScale)
})
watch(
[() => tasks.value.length, () => todayEvents.value.length, pageLoading],
async () => {
await nextTick()
syncDashboardMainScale()
},
{ flush: 'post' },
)
watch(
sidebarWidth,
async () => {
await nextTick()
syncDashboardMainScale()
},
{ flush: 'post' },
)
</script>
<template>
@@ -368,82 +382,74 @@ onBeforeUnmount(() => {
<span class="dashboard-splitter__line" />
</div>
<section class="dashboard-main">
<header class="dashboard-topbar glass-panel">
<div>
<div class="dashboard-topbar__brandline">
<strong>AI 智慧日程系统</strong>
<span>{{ pageTitleDate }}</span>
<section ref="dashboardMainRef" class="dashboard-main">
<div ref="dashboardMainInnerRef" class="dashboard-main__scaled" :style="dashboardMainScaleStyle">
<header ref="dashboardTopbarRef" class="dashboard-topbar glass-panel">
<div>
<div class="dashboard-topbar__brandline">
<strong>AI 智慧日程系统</strong>
<span>{{ pageTitleDate }}</span>
</div>
</div>
</div>
<div class="dashboard-topbar__actions">
<button
type="button"
class="dashboard-topbar__logout"
:disabled="logoutLoading"
@click="handleLogout"
>
{{ logoutLoading ? '退出中…' : '登出' }}
</button>
<div class="dashboard-topbar__profile">
<strong>{{ greetingName }}</strong>
<span>{{ greetingName.slice(0, 1).toUpperCase() }}</span>
<div class="dashboard-topbar__actions">
<button
type="button"
class="dashboard-topbar__logout"
:disabled="logoutLoading"
@click="handleLogout"
>
{{ logoutLoading ? '退出中…' : '登出' }}
</button>
<div class="dashboard-topbar__profile">
<strong>{{ greetingName }}</strong>
<span>{{ greetingName.slice(0, 1).toUpperCase() }}</span>
</div>
</div>
</div>
</header>
</header>
<div class="dashboard-content page-shell">
<TodayTimeline :events="todayEvents" :loading="scheduleLoading || pageLoading" />
<div ref="dashboardContentRef" class="dashboard-content page-shell">
<TodayTimeline :events="todayEvents" :loading="scheduleLoading || pageLoading" />
<div class="dashboard-actions">
<button type="button" class="dashboard-actions__primary" @click="openCreateTaskDialog">
添加任务
</button>
</div>
<section class="dashboard-quadrants">
<TaskQuadrantCard
v-for="group in quadrantOrder"
:key="group"
:title="quadrantMeta[group].title"
:caption="quadrantMeta[group].caption"
:tone="quadrantMeta[group].tone"
:empty-text="quadrantMeta[group].emptyText"
:count="groupedTasks[group].length"
:tasks="groupedTasks[group]"
:loading="taskLoading || pageLoading"
@toggle="handleTaskToggle"
/>
</section>
<section class="dashboard-import glass-panel">
<div class="dashboard-import__content">
<p class="dashboard-import__eyebrow">课程导入</p>
<h2>导入课表</h2>
<p>导入课表后可以在安排日程时避开上课时间后续我会继续把导入流程页接完整</p>
<button type="button" class="dashboard-import__button" @click="handleCourseImportEntry">
开始导入
<div class="dashboard-actions">
<button type="button" class="dashboard-actions__primary" @click="openCreateTaskDialog">
添加任务
</button>
</div>
<div class="dashboard-import__shape">
<span class="dashboard-import__shape-ring" />
<span class="dashboard-import__shape-core" />
</div>
</section>
<section class="dashboard-quadrants">
<TaskQuadrantCard
v-for="group in quadrantOrder"
:key="group"
:title="quadrantMeta[group].title"
:caption="quadrantMeta[group].caption"
:tone="quadrantMeta[group].tone"
:empty-text="quadrantMeta[group].emptyText"
:count="groupedTasks[group].length"
:tasks="groupedTasks[group]"
:loading="taskLoading || pageLoading"
@toggle="handleTaskToggle"
/>
</section>
<section class="dashboard-import glass-panel">
<div class="dashboard-import__content">
<p class="dashboard-import__eyebrow">课程导入</p>
<h2>导入课表</h2>
<p>导入课表后可以在安排日程时避开上课时间后续我会继续把导入流程页接完整</p>
<button type="button" class="dashboard-import__button" @click="handleCourseImportEntry">
开始导入
</button>
</div>
<div class="dashboard-import__shape">
<span class="dashboard-import__shape-ring" />
<span class="dashboard-import__shape-core" />
</div>
</section>
</div>
</div>
</section>
<div
class="dashboard-splitter"
role="separator"
aria-label="调整 AI 助手宽度"
@pointerdown.prevent="startResize('assistant', $event)"
>
<span class="dashboard-splitter__line" />
</div>
<AssistantPanel class="dashboard-assistant" />
</div>
<el-dialog
@@ -494,15 +500,12 @@ onBeforeUnmount(() => {
.dashboard-layout {
--dashboard-sidebar-width: 78px;
--dashboard-assistant-width: 460px;
height: calc(100vh - 20px);
display: grid;
grid-template-columns:
var(--dashboard-sidebar-width)
10px
minmax(0, 1fr)
10px
var(--dashboard-assistant-width);
minmax(0, 1fr);
gap: 8px;
align-items: stretch;
}
@@ -598,12 +601,22 @@ onBeforeUnmount(() => {
.dashboard-main {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 10px;
overflow: hidden;
}
.dashboard-main__scaled {
--dashboard-main-scale: 1;
min-width: 0;
min-height: 0;
width: calc(100% / var(--dashboard-main-scale));
height: calc(100% / var(--dashboard-main-scale));
display: grid;
grid-template-rows: auto auto;
gap: 10px;
transform: scale(var(--dashboard-main-scale));
transform-origin: top left;
}
.dashboard-topbar {
border-radius: 24px;
padding: 18px 22px;
@@ -674,7 +687,7 @@ onBeforeUnmount(() => {
min-width: 0;
display: grid;
gap: 14px;
overflow-y: auto;
overflow-y: hidden;
overflow-x: hidden;
padding-right: 0;
align-content: start;
@@ -720,6 +733,7 @@ onBeforeUnmount(() => {
position: relative;
z-index: 1;
max-width: 460px;
padding-bottom: 22px;
}
.dashboard-import__eyebrow {
@@ -785,13 +799,6 @@ onBeforeUnmount(() => {
bottom: 2px;
}
.dashboard-assistant {
min-width: 0;
min-height: 0;
height: 100%;
align-self: stretch;
}
.dashboard-dialog :deep(.el-dialog) {
border-radius: 24px;
}
@@ -800,12 +807,6 @@ onBeforeUnmount(() => {
width: 100%;
}
@media (max-width: 1640px) {
.dashboard-layout {
--dashboard-assistant-width: 520px;
}
}
@media (max-width: 1380px) {
.dashboard-layout {
height: calc(100vh - 20px);
@@ -815,11 +816,6 @@ onBeforeUnmount(() => {
.dashboard-splitter {
display: none;
}
.dashboard-assistant {
grid-column: 2;
height: auto;
}
}
@media (max-width: 980px) {
@@ -840,11 +836,20 @@ onBeforeUnmount(() => {
justify-content: center;
}
.dashboard-main,
.dashboard-assistant {
.dashboard-main {
grid-column: auto;
}
.dashboard-main__scaled {
width: 100%;
height: 100%;
transform: none;
}
.dashboard-content {
overflow-y: auto;
}
.dashboard-quadrants {
grid-template-columns: 1fr;
}

View File

@@ -0,0 +1,837 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import {
applyBatchIntoSchedule,
createTaskClass,
deleteScheduleEntries,
deleteTaskClassItem,
getTaskClassDetail,
getTaskClassList,
getWeekSchedule,
smartPlanning,
smartPlanningMulti,
} from '@/api/scheduleCenter'
import CreateTaskClassDialog from '@/components/schedule/CreateTaskClassDialog.vue'
import TaskClassSidebar from '@/components/schedule/TaskClassSidebar.vue'
import WeekPlanningBoard from '@/components/schedule/WeekPlanningBoard.vue'
import type { ApplyBatchIntoScheduleItem, ScheduleWeekData, ScheduleWeekEvent, TaskClassDetail, TaskClassListItem } from '@/types/schedule'
import { formatHeaderDate } from '@/utils/date'
interface SidebarItem {
key: 'home' | 'task' | 'calendar' | 'ai'
label: string
short: string
to?: '/dashboard' | '/assistant' | '/schedule'
}
interface WeekDayHeader {
dayOfWeek: number
label: string
dateLabel: string
}
const router = useRouter()
const route = useRoute()
const sidebarItems: SidebarItem[] = [
{ key: 'home', label: '总览', short: '总', to: '/dashboard' },
{ key: 'task', label: '任务', short: '任' },
{ key: 'calendar', label: '日程', short: '程', to: '/schedule' },
{ key: 'ai', label: '助手', short: 'AI', to: '/assistant' },
]
const taskClassLoading = ref(false)
const taskClassDetailLoading = ref(false)
const weekLoading = ref(false)
const smartPlanningLoading = ref(false)
const applyingLoading = ref(false)
const deletingLoading = ref(false)
const createDialogVisible = ref(false)
const createDialogLoading = ref(false)
const taskClasses = ref<TaskClassListItem[]>([])
const expandedTaskClassId = ref<number | null>(null)
const expandedTaskClassDetail = ref<TaskClassDetail | null>(null)
const taskClassMultiSelectMode = ref(false)
const selectedTaskClassIds = ref<number[]>([])
const scheduleSelectionMode = ref(false)
const selectedScheduleEventIds = ref<number[]>([])
const liveWeeks = ref<ScheduleWeekData[]>([])
const previewWeeks = ref<ScheduleWeekData[] | null>(null)
const currentWeek = ref<number | null>(null)
const weekBase = ref<number | null>(null)
const baseMonday = ref<Date | null>(null)
const activeSidebarKey = computed<SidebarItem['key']>(() => {
if (route.path.startsWith('/assistant')) {
return 'ai'
}
if (route.path.startsWith('/schedule')) {
return 'calendar'
}
return 'home'
})
const effectiveSelectedTaskClassIds = computed(() => {
if (taskClassMultiSelectMode.value) {
return selectedTaskClassIds.value
}
return expandedTaskClassId.value ? [expandedTaskClassId.value] : []
})
const currentWeekData = computed(() => {
const source = previewWeeks.value ?? liveWeeks.value
if (!source.length) {
return null
}
const targetWeek = currentWeek.value ?? source[0].week
return source.find((item) => item.week === targetWeek) ?? source[0]
})
const weekHeaders = computed<WeekDayHeader[]>(() => {
const weekdayMap = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
return Array.from({ length: 7 }, (_, index) => {
const dayOfWeek = index + 1
const date = resolveDateByWeekDay(currentWeek.value, dayOfWeek)
return {
dayOfWeek,
label: weekdayMap[index],
dateLabel: date ? `${date.getDate()}` : '',
}
})
})
const weekLabel = computed(() => {
if (!currentWeek.value) {
return '第--周'
}
return `${numberToChinese(currentWeek.value)}`
})
const currentTermLabel = computed(() => {
const now = new Date()
const semester = now.getMonth() + 1 >= 8 ? '秋季学期' : '春季学期'
return `${now.getFullYear()}学年${semester}`
})
const showSmartPlanningButton = computed(() =>
!scheduleSelectionMode.value && effectiveSelectedTaskClassIds.value.length > 0,
)
const showDeleteModeButton = computed(() =>
!scheduleSelectionMode.value && effectiveSelectedTaskClassIds.value.length === 0,
)
const showApplyButton = computed(() =>
!scheduleSelectionMode.value &&
Boolean(previewWeeks.value?.length) &&
effectiveSelectedTaskClassIds.value.length === 1,
)
function handleSidebarNavigate(item: SidebarItem) {
if (item.to) {
if (route.path !== item.to) {
void router.push(item.to)
}
return
}
ElMessage.info(`${item.label} 页面正在开发中`)
}
function startOfWeek(date: Date) {
const next = new Date(date)
const day = next.getDay()
const diff = day === 0 ? -6 : 1 - day
next.setDate(next.getDate() + diff)
next.setHours(0, 0, 0, 0)
return next
}
function resolveDateByWeekDay(week: number | null, dayOfWeek: number) {
if (weekBase.value === null || !baseMonday.value || week === null) {
return null
}
const date = new Date(baseMonday.value)
date.setDate(baseMonday.value.getDate() + (week - weekBase.value) * 7 + (dayOfWeek - 1))
return date
}
function numberToChinese(value: number) {
const digits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九']
if (value <= 10) {
return value === 10 ? '十' : digits[value]
}
if (value < 20) {
return `${digits[value % 10]}`
}
const tens = Math.floor(value / 10)
const units = value % 10
return `${digits[tens]}${units ? digits[units] : ''}`
}
async function loadTaskClasses() {
taskClassLoading.value = true
try {
taskClasses.value = await getTaskClassList()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '任务类列表加载失败')
} finally {
taskClassLoading.value = false
}
}
async function loadWeekData(week?: number) {
weekLoading.value = true
try {
const result = await getWeekSchedule(week)
liveWeeks.value = result
if (result[0]?.week && weekBase.value === null) {
weekBase.value = result[0].week
baseMonday.value = startOfWeek(new Date())
}
if (typeof week === 'number') {
currentWeek.value = week
} else if (result[0]?.week) {
currentWeek.value = result[0].week
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '周日程加载失败')
} finally {
weekLoading.value = false
}
}
async function loadTaskClassDetail(taskClassId: number) {
taskClassDetailLoading.value = true
try {
expandedTaskClassDetail.value = await getTaskClassDetail(taskClassId)
} catch (error) {
expandedTaskClassDetail.value = null
ElMessage.error(error instanceof Error ? error.message : '任务类详情加载失败')
} finally {
taskClassDetailLoading.value = false
}
}
async function handleActivateTaskClass(taskClassId: number) {
if (taskClassMultiSelectMode.value) {
selectedTaskClassIds.value = selectedTaskClassIds.value.includes(taskClassId)
? selectedTaskClassIds.value.filter((id) => id !== taskClassId)
: [...selectedTaskClassIds.value, taskClassId]
return
}
scheduleSelectionMode.value = false
selectedScheduleEventIds.value = []
previewWeeks.value = null
if (expandedTaskClassId.value === taskClassId) {
expandedTaskClassId.value = null
expandedTaskClassDetail.value = null
return
}
expandedTaskClassId.value = taskClassId
expandedTaskClassDetail.value = null
await loadTaskClassDetail(taskClassId)
}
function handleToggleTaskClassMultiMode() {
taskClassMultiSelectMode.value = !taskClassMultiSelectMode.value
previewWeeks.value = null
scheduleSelectionMode.value = false
selectedScheduleEventIds.value = []
if (taskClassMultiSelectMode.value) {
selectedTaskClassIds.value = expandedTaskClassId.value ? [expandedTaskClassId.value] : []
expandedTaskClassId.value = null
expandedTaskClassDetail.value = null
return
}
selectedTaskClassIds.value = []
}
async function handleDeleteTaskItem(taskItemId: number) {
try {
await deleteTaskClassItem(taskItemId)
ElMessage.success('任务块已删除')
await loadTaskClasses()
if (expandedTaskClassId.value) {
await loadTaskClassDetail(expandedTaskClassId.value)
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除任务块失败')
}
}
async function handleSmartPlanning() {
const ids = effectiveSelectedTaskClassIds.value
if (ids.length === 0) {
ElMessage.info('请先选择任务类')
return
}
smartPlanningLoading.value = true
try {
previewWeeks.value = ids.length === 1 ? await smartPlanning(ids[0]!) : await smartPlanningMulti(ids)
if (previewWeeks.value[0]?.week) {
currentWeek.value = previewWeeks.value[0].week
}
ElMessage.success(ids.length === 1 ? '已生成粗排预览' : '已生成批量粗排预览')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '智能粗排失败')
} finally {
smartPlanningLoading.value = false
}
}
function toggleScheduleSelectionMode() {
scheduleSelectionMode.value = !scheduleSelectionMode.value
selectedScheduleEventIds.value = []
}
function handleToggleScheduleEvent(eventId: number) {
selectedScheduleEventIds.value = selectedScheduleEventIds.value.includes(eventId)
? selectedScheduleEventIds.value.filter((id) => id !== eventId)
: [...selectedScheduleEventIds.value, eventId]
}
async function handleDeleteSelectedScheduleEvents() {
const events = currentWeekData.value?.events.filter((event) => selectedScheduleEventIds.value.includes(event.id)) ?? []
if (!events.length) {
ElMessage.info('请先选择要解除安排的格子')
return
}
deletingLoading.value = true
try {
await deleteScheduleEntries(events.map((event) => ({
id: event.id,
delete_course: event.type === 'course',
delete_embedded_task: Boolean(event.embedded_task_info?.id),
})))
ElMessage.success('已完成解除安排')
scheduleSelectionMode.value = false
selectedScheduleEventIds.value = []
previewWeeks.value = null
await loadWeekData(currentWeek.value ?? undefined)
await loadTaskClasses()
if (expandedTaskClassId.value) {
await loadTaskClassDetail(expandedTaskClassId.value)
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '解除安排失败')
} finally {
deletingLoading.value = false
}
}
function buildApplyItemsFromPreview(weeks: ScheduleWeekData[]) {
const items: ApplyBatchIntoScheduleItem[] = []
for (const week of weeks) {
for (const event of week.events) {
if (event.status !== 'suggested') {
continue
}
const startSection = (event.order - 1) * 2 + 1
const taskItemId = event.type === 'course' && event.embedded_task_info?.id
? event.embedded_task_info.id
: event.id
items.push({
task_item_id: taskItemId,
week: week.week,
day_of_week: event.day_of_week,
start_section: startSection,
end_section: startSection + Math.max(1, event.span || 2) - 1,
embed_course_event_id: event.type === 'course' ? event.id : 0,
})
}
}
return items
}
async function handleApplyPreview() {
if (!previewWeeks.value?.length || effectiveSelectedTaskClassIds.value.length !== 1) {
ElMessage.info('当前预览暂不支持正式应用')
return
}
const items = buildApplyItemsFromPreview(previewWeeks.value)
if (!items.length) {
ElMessage.info('当前预览没有可应用的建议排程')
return
}
applyingLoading.value = true
try {
await applyBatchIntoSchedule(effectiveSelectedTaskClassIds.value[0]!, items)
ElMessage.success('已正式应用到日程')
previewWeeks.value = null
await loadWeekData(currentWeek.value ?? undefined)
await loadTaskClasses()
if (expandedTaskClassId.value) {
await loadTaskClassDetail(expandedTaskClassId.value)
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '正式应用失败')
} finally {
applyingLoading.value = false
}
}
async function handleCreateTaskClass(payload: Parameters<typeof createTaskClass>[0]) {
createDialogLoading.value = true
try {
await createTaskClass(payload)
ElMessage.success('任务类已创建')
createDialogVisible.value = false
await loadTaskClasses()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '创建任务类失败')
} finally {
createDialogLoading.value = false
}
}
function goPreviousWeek() {
if (currentWeek.value === null) {
return
}
currentWeek.value -= 1
}
function goNextWeek() {
if (currentWeek.value === null) {
return
}
currentWeek.value += 1
}
watch(currentWeek, async (nextWeek, previousWeek) => {
if (nextWeek === null || nextWeek === previousWeek) {
return
}
if (previewWeeks.value?.some((item) => item.week === nextWeek)) {
return
}
if (previewWeeks.value) {
previewWeeks.value = null
}
await loadWeekData(nextWeek)
})
onMounted(async () => {
await Promise.all([loadTaskClasses(), loadWeekData()])
})
</script>
<template>
<main class="schedule-page">
<div class="schedule-layout">
<aside class="dashboard-sidebar">
<div class="dashboard-sidebar__brand">S</div>
<nav class="dashboard-sidebar__nav">
<button
v-for="item in sidebarItems"
:key="item.key"
type="button"
class="dashboard-sidebar__nav-item"
:class="{ 'dashboard-sidebar__nav-item--active': item.key === activeSidebarKey }"
@click="handleSidebarNavigate(item)"
>
<span>{{ item.short }}</span>
<small>{{ item.label }}</small>
</button>
</nav>
<button type="button" class="dashboard-sidebar__settings"></button>
</aside>
<section class="schedule-shell">
<header class="schedule-topbar">
<div class="schedule-topbar__brand">
<span class="schedule-topbar__brand-icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="2" width="18" height="18" rx="5" fill="currentColor" />
<path d="M7 8H15V9.6H7V8ZM7 11H15V12.6H7V11ZM7 14H12V15.6H7V14Z" fill="white" />
</svg>
</span>
<strong>日程安排中心</strong>
</div>
<div class="schedule-topbar__meta">
<strong>{{ currentTermLabel }}</strong>
<span>当前日期: {{ formatHeaderDate(new Date()) }}</span>
</div>
</header>
<div class="schedule-main">
<TaskClassSidebar
:task-classes="taskClasses"
:loading="taskClassLoading"
:detail-loading="taskClassDetailLoading"
:expanded-task-class-id="expandedTaskClassId"
:expanded-task-class-detail="expandedTaskClassDetail"
:selected-task-class-ids="effectiveSelectedTaskClassIds"
:task-class-multi-select-mode="taskClassMultiSelectMode"
@activate="handleActivateTaskClass"
@toggle-multi-mode="handleToggleTaskClassMultiMode"
@create="createDialogVisible = true"
@delete-item="handleDeleteTaskItem"
/>
<section class="schedule-board-wrap">
<div class="schedule-board__toolbar">
<div class="schedule-board__toolbar-left">
<button
v-if="showDeleteModeButton"
type="button"
class="schedule-board__toolbar-button schedule-board__toolbar-button--ghost"
@click="toggleScheduleSelectionMode"
>
多选
</button>
<button
v-else-if="scheduleSelectionMode"
type="button"
class="schedule-board__toolbar-button schedule-board__toolbar-button--ghost"
@click="toggleScheduleSelectionMode"
>
取消多选
</button>
<button
v-if="showSmartPlanningButton"
type="button"
class="schedule-board__toolbar-button schedule-board__toolbar-button--primary"
:disabled="smartPlanningLoading"
@click="handleSmartPlanning"
>
{{ smartPlanningLoading ? '编排中…' : effectiveSelectedTaskClassIds.length > 1 ? '智能批量编排' : '智能一键编排' }}
</button>
</div>
<div class="schedule-board__toolbar-right">
<button
type="button"
class="schedule-board__toolbar-button schedule-board__toolbar-button--ghost"
@click="goPreviousWeek"
>
上一周
</button>
<button
type="button"
class="schedule-board__toolbar-button schedule-board__toolbar-button--primary"
@click="goNextWeek"
>
下一周
</button>
</div>
</div>
<WeekPlanningBoard
:week-label="weekLabel"
:week-headers="weekHeaders"
:week-data="currentWeekData"
:schedule-selection-mode="scheduleSelectionMode"
:selected-schedule-event-ids="selectedScheduleEventIds"
@toggle-schedule-event="handleToggleScheduleEvent"
/>
<div v-if="showApplyButton || scheduleSelectionMode" class="schedule-board__footer">
<button
v-if="showApplyButton"
type="button"
class="schedule-board__footer-button schedule-board__footer-button--primary"
:disabled="applyingLoading"
@click="handleApplyPreview"
>
{{ applyingLoading ? '应用中…' : '正式应用日程' }}
</button>
<button
v-if="scheduleSelectionMode"
type="button"
class="schedule-board__footer-button schedule-board__footer-button--danger"
:disabled="deletingLoading || selectedScheduleEventIds.length === 0"
@click="handleDeleteSelectedScheduleEvents"
>
{{ deletingLoading ? '处理中…' : '解除安排/删除课程' }}
</button>
</div>
</section>
</div>
</section>
</div>
<CreateTaskClassDialog
v-model="createDialogVisible"
:loading="createDialogLoading"
@submit="handleCreateTaskClass"
/>
</main>
</template>
<style scoped>
.schedule-page {
height: 100vh;
padding: 10px;
overflow: hidden;
background: linear-gradient(180deg, #f6f9fd 0%, #eff4fb 100%);
}
.schedule-layout {
height: calc(100vh - 20px);
display: grid;
grid-template-columns: 78px minmax(0, 1fr);
gap: 8px;
}
.dashboard-sidebar {
height: 100%;
border-radius: 26px;
background: linear-gradient(180deg, #165ca8 0%, #104d8f 100%);
padding: 16px 12px;
display: grid;
grid-template-rows: auto 1fr auto;
gap: 16px;
}
.dashboard-sidebar__brand,
.dashboard-sidebar__settings {
width: 50px;
height: 50px;
border: none;
border-radius: 16px;
background: rgba(255, 255, 255, 0.14);
color: #fff;
font-weight: 800;
display: inline-flex;
align-items: center;
justify-content: center;
}
.dashboard-sidebar__nav {
display: grid;
gap: 12px;
align-content: start;
}
.dashboard-sidebar__nav-item {
width: 54px;
border: none;
border-radius: 16px;
background: transparent;
color: rgba(255, 255, 255, 0.74);
padding: 10px 8px;
display: grid;
justify-items: center;
gap: 5px;
cursor: pointer;
}
.dashboard-sidebar__nav-item span {
width: 32px;
height: 32px;
border-radius: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.08);
font-weight: 700;
}
.dashboard-sidebar__nav-item small {
font-size: 10px;
}
.dashboard-sidebar__nav-item--active {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.schedule-shell {
min-width: 0;
min-height: 0;
border-radius: 28px;
border: 1px solid rgba(215, 224, 237, 0.84);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 251, 255, 0.98));
overflow: hidden;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
}
.schedule-topbar {
padding: 14px 24px;
border-bottom: 1px solid rgba(218, 227, 239, 0.92);
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.schedule-topbar__brand {
display: inline-flex;
align-items: center;
gap: 16px;
}
.schedule-topbar__brand-icon {
width: 44px;
height: 44px;
border-radius: 14px;
background: linear-gradient(180deg, #1b64cf 0%, #0f56b7 100%);
color: #ffffff;
display: inline-flex;
align-items: center;
justify-content: center;
}
.schedule-topbar__brand strong {
color: #19263d;
font-size: 20px;
font-weight: 800;
}
.schedule-topbar__meta {
display: grid;
justify-items: end;
gap: 6px;
color: #8493aa;
}
.schedule-topbar__meta strong {
color: #344055;
font-size: 14px;
}
.schedule-topbar__meta span {
font-size: 12px;
}
.schedule-main {
min-width: 0;
min-height: 0;
display: grid;
grid-template-columns: 400px minmax(0, 1fr);
}
.schedule-board-wrap {
min-width: 0;
min-height: 0;
padding: 18px 22px 22px;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 14px;
}
.schedule-board__toolbar {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
}
.schedule-board__toolbar-left,
.schedule-board__toolbar-right {
display: flex;
gap: 10px;
align-items: center;
}
.schedule-board__toolbar-right {
margin-left: auto;
}
.schedule-board__toolbar-button,
.schedule-board__footer-button {
height: 36px;
border-radius: 10px;
border: 1px solid transparent;
padding: 0 18px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: border-color 0.16s ease, background-color 0.16s ease, color 0.16s ease;
}
.schedule-board__toolbar-button--primary,
.schedule-board__footer-button--primary {
background: linear-gradient(180deg, #1d64d1 0%, #1157bd 100%);
color: #ffffff;
}
.schedule-board__toolbar-button--primary:hover,
.schedule-board__footer-button--primary:hover {
background: linear-gradient(180deg, #1757b8 0%, #0f4ea9 100%);
}
.schedule-board__toolbar-button--ghost {
border-color: rgba(27, 96, 208, 0.22);
background: #ffffff;
color: #1e66d4;
}
.schedule-board__toolbar-button--ghost:hover {
border-color: rgba(27, 96, 208, 0.38);
background: #f2f7ff;
}
.schedule-board__footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.schedule-board__footer-button--danger {
background: #bb3326;
color: #ffffff;
}
.schedule-board__footer-button--danger:disabled,
.schedule-board__footer-button--primary:disabled,
.schedule-board__toolbar-button:disabled {
opacity: 0.46;
cursor: not-allowed;
}
@media (max-width: 1520px) {
.schedule-main {
grid-template-columns: 360px minmax(0, 1fr);
}
}
@media (max-width: 1180px) {
.schedule-layout {
grid-template-columns: 1fr;
}
.dashboard-sidebar {
display: none;
}
.schedule-main {
grid-template-columns: 1fr;
}
}
</style>