Version: 0.9.46.dev.260427

后端:
1. taskclass 执行闭环继续收紧——Plan / Execute 全面切到“最小工具闭环”视角,明确学习目标/总节数/禁排时段/排除星期默认停留 taskclass 域;未给日期范围时禁止擅自补 start_date/end_date,upsert_task_class 重试前先做写前检查并区分“内部表示修正”与“必须追问用户”的关键时间事实
2. QuickTask / TaskQuery 轻量链路继续收敛——新增 model/taskquery_contract.go 统一查询协议,QuickTaskDeps / start.go 改用 model 层参数;删除 query_tasks / quick_note_create 旧工具实现,避免任务查询与随口记再回流 execute 工具链
3. schedule 微调工具继续瘦身——下线 spread_even / min_context_switch 及其复合规划逻辑,清理 analyze_load / analyze_subjects / analyze_context / analyze_tolerance 等历史能力;execute 顺序策略收敛为局部 move / swap,提示词与工具目录仅暴露当前真实可用工具
4. 执行与时间线体验补齐——execute 为流式 speak 补发归一化尾部,避免 deliver 文案黏连;前端时间线新增 interrupt / status 协议识别、工具事件归并与状态过滤,减少 ToolTrace 重复和会话重建误判
前端:
5. AssistantPanel 适配新版 timeline extra 事件——schedule_agent.ts 补齐 interrupt / status kind,工具调用与结果按摘要/参数/工具名合并,恢复历史时不再把协议事件误判成用户消息
This commit is contained in:
LoveLosita
2026-04-27 12:20:17 +08:00
parent 66c06eed0a
commit 736ba0cff3
25 changed files with 425 additions and 2173 deletions

View File

@@ -1,473 +0,0 @@
package logic
import (
"fmt"
"sort"
"strings"
)
// RefineTaskCandidate 表示复合工具规划阶段可移动的任务候选。
//
// 职责边界:
// 1. 只承载“任务当前坐标 + 规划所需标签”;
// 2. 不承载冲突判断、窗口判断等执行期逻辑;
// 3. 由调用方保证 task_item_id 唯一且为正数。
type RefineTaskCandidate struct {
TaskItemID int
Week int
DayOfWeek int
SectionFrom int
SectionTo int
Name string
ContextTag string
OriginRank int
}
// RefineSlotCandidate 表示复合工具可选落点(坑位)。
//
// 职责边界:
// 1. 只描述可候选的时段坐标;
// 2. 不描述“为什么可用”,可用性由调用方预先筛好;
// 3. Span 由 SectionFrom/SectionTo 推导,不单独存储。
type RefineSlotCandidate struct {
Week int
DayOfWeek int
SectionFrom int
SectionTo int
}
// RefineMovePlanItem 表示“任务 -> 目标坑位”的确定性规划结果。
type RefineMovePlanItem struct {
TaskItemID int
ToWeek int
ToDay int
ToSectionFrom int
ToSectionTo int
}
// RefineCompositePlanOptions 是复合规划器的可选辅助输入。
//
// 说明:
// 1. ExistingDayLoad 用于提供“目标范围内的既有负载基线”,用于均匀铺开打分;
// 2. key 约定为 "week-day",例如 "16-3"
// 3. 未提供时,规划器按 0 基线处理。
type RefineCompositePlanOptions struct {
ExistingDayLoad map[string]int
}
// PlanEvenSpreadMoves 规划“均匀铺开”的确定性移动方案。
//
// 步骤化说明:
// 1. 先按稳定顺序归一化任务与坑位,保证同输入必同输出;
// 2. 逐任务选择“投放后日负载最小”的坑位,主目标是降低日负载离散度;
// 3. 同分时按时间更早优先,进一步保证确定性;
// 4. 若某任务不存在同跨度坑位,直接失败并返回明确错误。
func PlanEvenSpreadMoves(tasks []RefineTaskCandidate, slots []RefineSlotCandidate, options RefineCompositePlanOptions) ([]RefineMovePlanItem, error) {
normalizedTasks, err := normalizeRefineTasks(tasks)
if err != nil {
return nil, err
}
normalizedSlots, err := normalizeRefineSlots(slots)
if err != nil {
return nil, err
}
if len(normalizedSlots) < len(normalizedTasks) {
return nil, fmt.Errorf("可用坑位不足tasks=%d, slots=%d", len(normalizedTasks), len(normalizedSlots))
}
// 1. dayLoad 记录“当前已占 + 本次规划已分配”的日负载。
// 2. 这里先写入调用方提供的既有基线,再在循环中动态递增。
dayLoad := make(map[string]int, len(options.ExistingDayLoad)+len(normalizedSlots))
for key, value := range options.ExistingDayLoad {
if value <= 0 {
continue
}
dayLoad[strings.TrimSpace(key)] = value
}
used := make([]bool, len(normalizedSlots))
moves := make([]RefineMovePlanItem, 0, len(normalizedTasks))
selectedSlots := make([]RefineSlotCandidate, 0, len(normalizedTasks))
for _, task := range normalizedTasks {
taskSpan := sectionSpan(task.SectionFrom, task.SectionTo)
bestIdx := -1
bestScore := int(^uint(0) >> 1) // max int
for idx, slot := range normalizedSlots {
if used[idx] {
continue
}
if sectionSpan(slot.SectionFrom, slot.SectionTo) != taskSpan {
continue
}
if slotOverlapsAny(slot, selectedSlots) {
continue
}
dayKey := composeDayKey(slot.Week, slot.DayOfWeek)
projectedLoad := dayLoad[dayKey] + 1
// 1. projectedLoad 是主目标(越小越均衡);
// 2. idx 是次级目标(越早的坑位越优先,保证稳定)。
score := projectedLoad*10000 + idx
if score < bestScore {
bestScore = score
bestIdx = idx
}
}
if bestIdx < 0 {
return nil, fmt.Errorf("任务 id=%d 无可用同跨度坑位", task.TaskItemID)
}
chosen := normalizedSlots[bestIdx]
used[bestIdx] = true
selectedSlots = append(selectedSlots, chosen)
dayLoad[composeDayKey(chosen.Week, chosen.DayOfWeek)]++
moves = append(moves, RefineMovePlanItem{
TaskItemID: task.TaskItemID,
ToWeek: chosen.Week,
ToDay: chosen.DayOfWeek,
ToSectionFrom: chosen.SectionFrom,
ToSectionTo: chosen.SectionTo,
})
}
return moves, nil
}
// PlanMinContextSwitchMoves 规划“同科目上下文切换最少”的确定性移动方案。
//
// 步骤化说明:
// 1. 先把任务按 context_tag 分组,目标是让同组任务尽量连续;
// 2. 分组顺序按“组大小降序 + 最早 origin_rank + 标签字典序”稳定排序;
// 3. 组内按任务稳定顺序排,再顺序填入时间上最早可用同跨度坑位;
// 4. 若某任务不存在同跨度坑位,立即失败并返回明确错误。
func PlanMinContextSwitchMoves(tasks []RefineTaskCandidate, slots []RefineSlotCandidate, _ RefineCompositePlanOptions) ([]RefineMovePlanItem, error) {
normalizedTasks, err := normalizeRefineTasks(tasks)
if err != nil {
return nil, err
}
normalizedSlots, err := normalizeRefineSlots(slots)
if err != nil {
return nil, err
}
if len(normalizedSlots) < len(normalizedTasks) {
return nil, fmt.Errorf("可用坑位不足tasks=%d, slots=%d", len(normalizedTasks), len(normalizedSlots))
}
type taskGroup struct {
ContextKey string
Tasks []RefineTaskCandidate
MinRank int
}
groupingKeys := buildMinContextGroupingKeys(normalizedTasks)
groupMap := make(map[string]*taskGroup)
groupOrder := make([]string, 0, len(normalizedTasks))
for _, task := range normalizedTasks {
key := groupingKeys[task.TaskItemID]
group, exists := groupMap[key]
if !exists {
group = &taskGroup{
ContextKey: key,
MinRank: normalizedOriginRank(task),
}
groupMap[key] = group
groupOrder = append(groupOrder, key)
}
group.Tasks = append(group.Tasks, task)
if rank := normalizedOriginRank(task); rank < group.MinRank {
group.MinRank = rank
}
}
groups := make([]taskGroup, 0, len(groupMap))
for _, key := range groupOrder {
group := groupMap[key]
sort.SliceStable(group.Tasks, func(i, j int) bool {
return compareTaskOrder(group.Tasks[i], group.Tasks[j]) < 0
})
groups = append(groups, *group)
}
sort.SliceStable(groups, func(i, j int) bool {
if len(groups[i].Tasks) != len(groups[j].Tasks) {
return len(groups[i].Tasks) > len(groups[j].Tasks)
}
if groups[i].MinRank != groups[j].MinRank {
return groups[i].MinRank < groups[j].MinRank
}
return groups[i].ContextKey < groups[j].ContextKey
})
orderedTasks := make([]RefineTaskCandidate, 0, len(normalizedTasks))
for _, group := range groups {
orderedTasks = append(orderedTasks, group.Tasks...)
}
used := make([]bool, len(normalizedSlots))
moves := make([]RefineMovePlanItem, 0, len(orderedTasks))
selectedSlots := make([]RefineSlotCandidate, 0, len(orderedTasks))
for _, task := range orderedTasks {
taskSpan := sectionSpan(task.SectionFrom, task.SectionTo)
chosenIdx := -1
for idx, slot := range normalizedSlots {
if used[idx] {
continue
}
if sectionSpan(slot.SectionFrom, slot.SectionTo) != taskSpan {
continue
}
if slotOverlapsAny(slot, selectedSlots) {
continue
}
chosenIdx = idx
break
}
if chosenIdx < 0 {
return nil, fmt.Errorf("任务 id=%d 无可用同跨度坑位", task.TaskItemID)
}
chosen := normalizedSlots[chosenIdx]
used[chosenIdx] = true
selectedSlots = append(selectedSlots, chosen)
moves = append(moves, RefineMovePlanItem{
TaskItemID: task.TaskItemID,
ToWeek: chosen.Week,
ToDay: chosen.DayOfWeek,
ToSectionFrom: chosen.SectionFrom,
ToSectionTo: chosen.SectionTo,
})
}
return moves, nil
}
func normalizeRefineTasks(tasks []RefineTaskCandidate) ([]RefineTaskCandidate, error) {
if len(tasks) == 0 {
return nil, fmt.Errorf("任务列表为空")
}
normalized := make([]RefineTaskCandidate, 0, len(tasks))
seen := make(map[int]struct{}, len(tasks))
for _, task := range tasks {
if task.TaskItemID <= 0 {
return nil, fmt.Errorf("存在非法 task_item_id=%d", task.TaskItemID)
}
if _, exists := seen[task.TaskItemID]; exists {
return nil, fmt.Errorf("任务 id=%d 重复", task.TaskItemID)
}
if !isValidDay(task.DayOfWeek) {
return nil, fmt.Errorf("任务 id=%d day_of_week 非法=%d", task.TaskItemID, task.DayOfWeek)
}
if !isValidSection(task.SectionFrom, task.SectionTo) {
return nil, fmt.Errorf("任务 id=%d 节次区间非法=%d-%d", task.TaskItemID, task.SectionFrom, task.SectionTo)
}
seen[task.TaskItemID] = struct{}{}
normalized = append(normalized, task)
}
sort.SliceStable(normalized, func(i, j int) bool {
return compareTaskOrder(normalized[i], normalized[j]) < 0
})
return normalized, nil
}
func normalizeRefineSlots(slots []RefineSlotCandidate) ([]RefineSlotCandidate, error) {
if len(slots) == 0 {
return nil, fmt.Errorf("可用坑位为空")
}
normalized := make([]RefineSlotCandidate, 0, len(slots))
seen := make(map[string]struct{}, len(slots))
for _, slot := range slots {
if slot.Week <= 0 {
return nil, fmt.Errorf("存在非法 week=%d", slot.Week)
}
if !isValidDay(slot.DayOfWeek) {
return nil, fmt.Errorf("存在非法 day_of_week=%d", slot.DayOfWeek)
}
if !isValidSection(slot.SectionFrom, slot.SectionTo) {
return nil, fmt.Errorf("存在非法节次区间=%d-%d", slot.SectionFrom, slot.SectionTo)
}
key := fmt.Sprintf("%d-%d-%d-%d", slot.Week, slot.DayOfWeek, slot.SectionFrom, slot.SectionTo)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
normalized = append(normalized, slot)
}
sort.SliceStable(normalized, func(i, j int) bool {
if normalized[i].Week != normalized[j].Week {
return normalized[i].Week < normalized[j].Week
}
if normalized[i].DayOfWeek != normalized[j].DayOfWeek {
return normalized[i].DayOfWeek < normalized[j].DayOfWeek
}
if normalized[i].SectionFrom != normalized[j].SectionFrom {
return normalized[i].SectionFrom < normalized[j].SectionFrom
}
return normalized[i].SectionTo < normalized[j].SectionTo
})
return normalized, nil
}
func compareTaskOrder(a, b RefineTaskCandidate) int {
rankA := normalizedOriginRank(a)
rankB := normalizedOriginRank(b)
if rankA != rankB {
return rankA - rankB
}
if a.Week != b.Week {
return a.Week - b.Week
}
if a.DayOfWeek != b.DayOfWeek {
return a.DayOfWeek - b.DayOfWeek
}
if a.SectionFrom != b.SectionFrom {
return a.SectionFrom - b.SectionFrom
}
if a.SectionTo != b.SectionTo {
return a.SectionTo - b.SectionTo
}
return a.TaskItemID - b.TaskItemID
}
func normalizedOriginRank(task RefineTaskCandidate) int {
if task.OriginRank > 0 {
return task.OriginRank
}
// 1. 无 origin_rank 时回退到较大稳定值,避免把“未知顺序”抢到前面。
// 2. 叠加 task_id 作为细粒度稳定因子,保证排序可复现。
return 1_000_000 + task.TaskItemID
}
func normalizeContextKey(tag string) string {
text := strings.TrimSpace(tag)
if text == "" {
return "General"
}
return text
}
// buildMinContextGroupingKeys 为 MinContextSwitch 生成“实际用于聚类”的分组键。
//
// 步骤化说明:
// 1. 先优先使用现有 ContextTag避免影响已稳定的显式标签链路
// 2. 若整批任务只剩一个粗粒度标签(例如全是 General/High-Logic说明标签对“同科目连续”帮助不足
// 3. 此时再基于任务名做学科关键词兜底,只在确实能拉开分组时启用;
// 4. 若任务名也无法识别,则继续回落到原 ContextTag保证行为可预测。
func buildMinContextGroupingKeys(tasks []RefineTaskCandidate) map[int]string {
keys := make(map[int]string, len(tasks))
distinctExplicit := make(map[string]struct{}, len(tasks))
distinctNonCoarse := make(map[string]struct{}, len(tasks))
for _, task := range tasks {
key := normalizeContextKey(task.ContextTag)
keys[task.TaskItemID] = key
distinctExplicit[key] = struct{}{}
if !isCoarseContextKey(key) {
distinctNonCoarse[key] = struct{}{}
}
}
// 1. 当显式标签已经至少区分出两类“非粗标签”时,直接尊重上游语义;
// 2. 避免把已稳定的 context_tag 分组再改写成名称启发式结果。
if len(distinctNonCoarse) >= 2 {
return keys
}
// 1. 若显式标签本来就有 2 类及以上,且不全是粗标签,也继续沿用;
// 2. 只有“整批退化到同一个粗标签”时,才值得尝试名称兜底。
if len(distinctExplicit) > 1 && len(distinctNonCoarse) > 0 {
return keys
}
inferredKeys := make(map[int]string, len(tasks))
distinctInferred := make(map[string]struct{}, len(tasks))
for _, task := range tasks {
inferred := inferSubjectContextKeyFromTaskName(task.Name)
if inferred == "" {
inferred = keys[task.TaskItemID]
}
inferredKeys[task.TaskItemID] = inferred
distinctInferred[inferred] = struct{}{}
}
if len(distinctInferred) >= 2 {
return inferredKeys
}
return keys
}
func isCoarseContextKey(key string) bool {
switch strings.ToLower(strings.TrimSpace(key)) {
case "", "general", "high-logic", "high_logic", "memory", "review":
return true
default:
return false
}
}
func inferSubjectContextKeyFromTaskName(name string) string {
text := strings.ToLower(strings.TrimSpace(name))
if text == "" {
return ""
}
subjectKeywordGroups := []struct {
keywords []string
groupKey string
}{
{
keywords: []string{
"概率", "随机事件", "随机变量", "条件概率", "全概率", "贝叶斯",
"分布", "大数定律", "中心极限定理", "参数估计", "期望", "方差", "协方差", "相关系数",
},
groupKey: "subject:probability",
},
{
keywords: []string{
"数制", "码制", "逻辑代数", "逻辑函数", "卡诺图", "译码器", "编码器",
"数据选择器", "触发器", "时序电路", "状态图", "状态化简", "计数器", "寄存器", "数电",
},
groupKey: "subject:digital_logic",
},
{
keywords: []string{
"命题逻辑", "谓词逻辑", "量词", "等值演算", "集合", "关系", "函数",
"图论", "欧拉回路", "哈密顿", "生成树", "离散", "组合数学", "容斥", "递推",
},
groupKey: "subject:discrete_math",
},
}
for _, group := range subjectKeywordGroups {
for _, keyword := range group.keywords {
if strings.Contains(text, keyword) {
return group.groupKey
}
}
}
return ""
}
func composeDayKey(week, day int) string {
return fmt.Sprintf("%d-%d", week, day)
}
func sectionSpan(from, to int) int {
return to - from + 1
}
func isValidDay(day int) bool {
return day >= 1 && day <= 7
}
func isValidSection(from, to int) bool {
if from < 1 || to > 12 {
return false
}
return from <= to
}
func slotOverlapsAny(candidate RefineSlotCandidate, selected []RefineSlotCandidate) bool {
for _, current := range selected {
if current.Week != candidate.Week || current.DayOfWeek != candidate.DayOfWeek {
continue
}
if current.SectionFrom <= candidate.SectionTo && candidate.SectionFrom <= current.SectionTo {
return true
}
}
return false
}

View File

@@ -1,131 +0,0 @@
package logic
import (
"sort"
"testing"
)
func TestPlanEvenSpreadMovesPrefersLowerLoadDay(t *testing.T) {
tasks := []RefineTaskCandidate{
{TaskItemID: 101, Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, OriginRank: 1},
{TaskItemID: 102, Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, OriginRank: 2},
}
slots := []RefineSlotCandidate{
{Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2},
{Week: 12, DayOfWeek: 2, SectionFrom: 1, SectionTo: 2},
{Week: 12, DayOfWeek: 3, SectionFrom: 1, SectionTo: 2},
}
moves, err := PlanEvenSpreadMoves(tasks, slots, RefineCompositePlanOptions{
ExistingDayLoad: map[string]int{
composeDayKey(12, 1): 5,
composeDayKey(12, 2): 1,
composeDayKey(12, 3): 0,
},
})
if err != nil {
t.Fatalf("PlanEvenSpreadMoves 返回错误: %v", err)
}
if len(moves) != 2 {
t.Fatalf("期望移动 2 条,实际=%d", len(moves))
}
// 1. 低负载日(周三)应优先被填充;
// 2. 第二条应落在次低负载日(周二),而不是高负载日(周一)。
weekDayByID := make(map[int][2]int, len(moves))
for _, move := range moves {
weekDayByID[move.TaskItemID] = [2]int{move.ToWeek, move.ToDay}
}
if got := weekDayByID[101]; got != [2]int{12, 3} {
t.Fatalf("任务101应优先落到 W12D3实际=%v", got)
}
if got := weekDayByID[102]; got != [2]int{12, 2} {
t.Fatalf("任务102应落到 W12D2实际=%v", got)
}
}
func TestPlanMinContextSwitchMovesGroupsSameContext(t *testing.T) {
tasks := []RefineTaskCandidate{
{TaskItemID: 201, Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, ContextTag: "数学", OriginRank: 1},
{TaskItemID: 202, Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, ContextTag: "算法", OriginRank: 2},
{TaskItemID: 203, Week: 16, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, ContextTag: "数学", OriginRank: 3},
}
slots := []RefineSlotCandidate{
{Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2},
{Week: 12, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4},
{Week: 12, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6},
}
moves, err := PlanMinContextSwitchMoves(tasks, slots, RefineCompositePlanOptions{})
if err != nil {
t.Fatalf("PlanMinContextSwitchMoves 返回错误: %v", err)
}
if len(moves) != 3 {
t.Fatalf("期望移动 3 条,实际=%d", len(moves))
}
// 1. “数学”有 2 条,分组后应先连续落在最早两个坑位;
// 2. 因此 201 与 203 对应的目标节次应是 1-2 与 3-4顺序由 origin_rank 决定)。
sort.SliceStable(moves, func(i, j int) bool {
if moves[i].ToWeek != moves[j].ToWeek {
return moves[i].ToWeek < moves[j].ToWeek
}
if moves[i].ToDay != moves[j].ToDay {
return moves[i].ToDay < moves[j].ToDay
}
return moves[i].ToSectionFrom < moves[j].ToSectionFrom
})
if moves[0].TaskItemID != 201 || moves[1].TaskItemID != 203 {
t.Fatalf("期望前两个坑位由同上下文任务占据,实际=%+v", moves)
}
if moves[2].TaskItemID != 202 {
t.Fatalf("期望最后一个坑位为算法任务,实际=%+v", moves[2])
}
}
func TestPlanMinContextSwitchMovesFallsBackToTaskNameWhenAllGeneral(t *testing.T) {
tasks := []RefineTaskCandidate{
{TaskItemID: 301, Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, Name: "随机事件与概率基础概念复习", ContextTag: "General", OriginRank: 1},
{TaskItemID: 302, Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, Name: "数制、码制与逻辑代数基础", ContextTag: "General", OriginRank: 2},
{TaskItemID: 303, Week: 16, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, Name: "第二章 条件概率与全概率公式", ContextTag: "General", OriginRank: 3},
}
slots := []RefineSlotCandidate{
{Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2},
{Week: 12, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4},
{Week: 12, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6},
}
moves, err := PlanMinContextSwitchMoves(tasks, slots, RefineCompositePlanOptions{})
if err != nil {
t.Fatalf("PlanMinContextSwitchMoves 返回错误: %v", err)
}
if len(moves) != 3 {
t.Fatalf("期望移动 3 条,实际=%d", len(moves))
}
sort.SliceStable(moves, func(i, j int) bool {
if moves[i].ToWeek != moves[j].ToWeek {
return moves[i].ToWeek < moves[j].ToWeek
}
if moves[i].ToDay != moves[j].ToDay {
return moves[i].ToDay < moves[j].ToDay
}
return moves[i].ToSectionFrom < moves[j].ToSectionFrom
})
if moves[0].TaskItemID != 301 || moves[1].TaskItemID != 303 {
t.Fatalf("期望概率任务通过名称兜底连续聚类,实际=%+v", moves)
}
if moves[2].TaskItemID != 302 {
t.Fatalf("期望数电任务落在最后一个坑位,实际=%+v", moves[2])
}
}
func TestPlanEvenSpreadMovesReturnsErrorWhenSpanNotMatched(t *testing.T) {
tasks := []RefineTaskCandidate{
{TaskItemID: 301, Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 3, OriginRank: 1}, // span=3
}
slots := []RefineSlotCandidate{
{Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2}, // span=2
}
_, err := PlanEvenSpreadMoves(tasks, slots, RefineCompositePlanOptions{})
if err == nil {
t.Fatalf("期望 span 不匹配时报错,实际 err=nil")
}
}

View File

@@ -173,7 +173,7 @@ func SmartPlanningMainLogic(schedules []model.Schedule, taskClass *model.TaskCla
if err != nil {
return nil, err
}
//3.把这些时间通过DTO函数回填到涉<EFBFBD><EFBFBD>周的 UserWeekSchedule 结构中,供前端展示
// 3. 把这些时间通过 DTO 函数回填到涉周的 UserWeekSchedule 结构中,供前端展示
return conv.PlanningResultToUserWeekSchedules(schedules, allocatedItems), nil
}