Version: 0.9.11.dev.260409

后端:
1. conv 并行迁移与切流接线(旧目录下沉到 newAgent/conv)
   - 新建 newAgent/conv/schedule_provider.go、schedule_state.go、schedule_preview.go、schedule_persist.go,保持原有排程转换/预览/持久化能力;
   - 删除旧目录 conv/schedule_provider.go、schedule_state.go、schedule_preview.go、schedule_persist.go;
   - 更新 cmd/start.go 与 service/agentsvc/agent_newagent.go,ScheduleProvider/SchedulePersistor 与 preview 转换统一切到 newAgent/conv;
   - 删除旧 conv/schedule_state_test.go(迁移期测试文件清理)。
2. execute 循环上下文收口增强(历史归档 + 当前轮清晰化)
   - 更新 node/chat.go:仅在 completed 收口时写 execute_loop_closed marker,供后续 prompt 分层归档;
   - 更新 prompt/execute_context.go:msg1/msg2 升级为 V3,按收口标记拆分“历史归档 loop / 当前活跃 loop”,并增加 msg1 长度预算裁剪;
   - 更新 node/execute.go:新增 execute 置顶上下文同步(execution_context/current_step),在轮次开始与 next_plan 后即时刷新;
   - 更新 prompt/execute.go + execute_context.go:补齐“当前计划步骤 + done_when”强约束,禁止未达成判定时提前 next_plan。
3. 图路由与执行策略微调
   - 更新 graph/common_graph.go:Plan/Confirm 分支允许直接进入 Deliver 收口;
   - 更新 node/plan.go:always_execute 链路下补发计划摘要并写入历史,保证自动执行与手动确认文案一致;
   - 更新 model/common_state.go:DefaultMaxRounds 从 30 提升到 60。
4. 复合工具规划器重构(去重实现,复用 logic 公共能力)
   - 更新 tools/compound_tools.go:min_context_switch / spread_even 改为调用 backend/logic 规划器(PlanMinContextSwitchMoves / PlanEvenSpreadMoves);
   - 新增 state_id↔logic_id 映射层,统一入参与回填,避免工具层与规划层 ID 语义耦合;
   - 删除 compound_tools 内部重复的规划/归一化/分组/打分实现,减少第三份复制逻辑。
5. 同步调试与文档
   - 更新 newAgent/Log.txt 调试日志;
   - 新增 memory/记忆模块实施计划.md(面试优先版到产品可用版的落地路线)。
前端:无
仓库:无
This commit is contained in:
Losita
2026-04-09 22:20:30 +08:00
parent 821c2cde5d
commit 574d44c332
17 changed files with 9470 additions and 4539 deletions

View File

@@ -6,12 +6,12 @@ import (
"log"
"github.com/LoveLosita/smartflow/backend/api"
"github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/dao"
kafkabus "github.com/LoveLosita/smartflow/backend/infra/kafka"
outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox"
"github.com/LoveLosita/smartflow/backend/inits"
"github.com/LoveLosita/smartflow/backend/middleware"
newagentconv "github.com/LoveLosita/smartflow/backend/newAgent/conv"
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
"github.com/LoveLosita/smartflow/backend/pkg"
"github.com/LoveLosita/smartflow/backend/routers"
@@ -108,8 +108,8 @@ func Start() {
// newAgent 依赖接线。
agentService.SetAgentStateStore(dao.NewAgentStateStoreAdapter(cacheRepo))
agentService.SetToolRegistry(newagenttools.NewDefaultRegistry())
agentService.SetScheduleProvider(conv.NewScheduleProvider(scheduleRepo, taskClassRepo))
agentService.SetSchedulePersistor(conv.NewSchedulePersistorAdapter(manager))
agentService.SetScheduleProvider(newagentconv.NewScheduleProvider(scheduleRepo, taskClassRepo))
agentService.SetSchedulePersistor(newagentconv.NewSchedulePersistorAdapter(manager))
// API 层初始化。
userApi := api.NewUserHandler(userService)

View File

@@ -1,279 +0,0 @@
package conv
import (
"testing"
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
)
// buildTestState 构造最小可用的 ScheduleStateDayMapping 让 expandToCoords 能正常工作。
func buildTestState(days []newagenttools.DayMapping, tasks []newagenttools.ScheduleTask) *newagenttools.ScheduleState {
return &newagenttools.ScheduleState{
Window: newagenttools.ScheduleWindow{
TotalDays: len(days),
DayMapping: days,
},
Tasks: tasks,
}
}
// defaultDays 返回 3 天的 DayMappingday1=week3/dow1, day2=week3/dow2, day3=week3/dow3
func defaultDays() []newagenttools.DayMapping {
return []newagenttools.DayMapping{
{DayIndex: 1, Week: 3, DayOfWeek: 1},
{DayIndex: 2, Week: 3, DayOfWeek: 2},
{DayIndex: 3, Week: 3, DayOfWeek: 3},
}
}
// ==================== DiffScheduleState: task_item place ====================
// TestDiff_PlaceTaskItem_NonEmbed 验证:普通放置 task_item 时 HostEventID=0。
func TestDiff_PlaceTaskItem_NonEmbed(t *testing.T) {
days := defaultDays()
original := buildTestState(days, []newagenttools.ScheduleTask{
{StateID: 1, Source: "task_item", SourceID: 10, Name: "复习线代", Status: "pending", Duration: 2},
})
modified := buildTestState(days, []newagenttools.ScheduleTask{
{
StateID: 1,
Source: "task_item",
SourceID: 10,
Name: "复习线代",
Status: "existing",
Slots: []newagenttools.TaskSlot{{Day: 1, SlotStart: 1, SlotEnd: 2}},
},
})
changes := DiffScheduleState(original, modified)
if len(changes) != 1 {
t.Fatalf("期望 1 个变更,实际 %d 个", len(changes))
}
c := changes[0]
if c.Type != ChangePlace {
t.Errorf("期望 ChangePlace实际 %s", c.Type)
}
if c.Source != "task_item" || c.SourceID != 10 {
t.Errorf("source 或 sourceID 错误: %s/%d", c.Source, c.SourceID)
}
if c.HostEventID != 0 {
t.Errorf("非嵌入路径 HostEventID 应为 0实际 %d", c.HostEventID)
}
if len(c.NewCoords) != 2 {
t.Errorf("期望 2 个节次坐标,实际 %d", len(c.NewCoords))
}
}
// TestDiff_PlaceTaskItem_Embed 验证:嵌入放置时 HostEventID = 宿主的 SourceID。
func TestDiff_PlaceTaskItem_Embed(t *testing.T) {
days := defaultDays()
// 原始宿主水课已安排guest 待安排
original := buildTestState(days, []newagenttools.ScheduleTask{
{
StateID: 100,
Source: "event",
SourceID: 999, // ScheduleEvent.ID of the host course
Name: "高数",
Status: "existing",
CanEmbed: true,
Slots: []newagenttools.TaskSlot{{Day: 2, SlotStart: 3, SlotEnd: 4}},
},
{StateID: 1, Source: "task_item", SourceID: 10, Name: "复习线代", Status: "pending", Duration: 2},
})
hostID := 100
// 修改后guest 嵌入到宿主
modified := buildTestState(days, []newagenttools.ScheduleTask{
{
StateID: 100,
Source: "event",
SourceID: 999,
Name: "高数",
Status: "existing",
CanEmbed: true,
Slots: []newagenttools.TaskSlot{{Day: 2, SlotStart: 3, SlotEnd: 4}},
EmbeddedBy: &[]int{1}[0],
},
{
StateID: 1,
Source: "task_item",
SourceID: 10,
Name: "复习线代",
Status: "existing",
Slots: []newagenttools.TaskSlot{{Day: 2, SlotStart: 3, SlotEnd: 4}},
EmbedHost: &hostID,
},
})
changes := DiffScheduleState(original, modified)
// 宿主 slots 未变,只有 guest 产生 place 变更
var placeChange *ScheduleChange
for i := range changes {
if changes[i].SourceID == 10 {
placeChange = &changes[i]
}
}
if placeChange == nil {
t.Fatal("未找到 task_item 的 place 变更")
}
if placeChange.HostEventID != 999 {
t.Errorf("嵌入路径 HostEventID 应为 999宿主 SourceID实际 %d", placeChange.HostEventID)
}
}
// ==================== DiffScheduleState: task_item unplace ====================
// TestDiff_UnplaceTaskItem_NonEmbed 验证:从普通位置移除时 HostEventID=0。
func TestDiff_UnplaceTaskItem_NonEmbed(t *testing.T) {
days := defaultDays()
original := buildTestState(days, []newagenttools.ScheduleTask{
{
StateID: 1,
Source: "task_item",
SourceID: 10,
Name: "复习线代",
Status: "existing",
Slots: []newagenttools.TaskSlot{{Day: 1, SlotStart: 5, SlotEnd: 6}},
},
})
modified := buildTestState(days, []newagenttools.ScheduleTask{
{StateID: 1, Source: "task_item", SourceID: 10, Name: "复习线代", Status: "pending"},
})
changes := DiffScheduleState(original, modified)
if len(changes) != 1 {
t.Fatalf("期望 1 个变更,实际 %d", len(changes))
}
c := changes[0]
if c.Type != ChangeUnplace {
t.Errorf("期望 ChangeUnplace实际 %s", c.Type)
}
if c.HostEventID != 0 {
t.Errorf("普通移除 HostEventID 应为 0实际 %d", c.HostEventID)
}
if len(c.OldCoords) != 2 {
t.Errorf("期望 2 个旧坐标,实际 %d", len(c.OldCoords))
}
}
// TestDiff_UnplaceTaskItem_Embed 验证:从嵌入位置移除时 HostEventID = 宿主 SourceID。
func TestDiff_UnplaceTaskItem_Embed(t *testing.T) {
days := defaultDays()
hostStateID := 100
original := buildTestState(days, []newagenttools.ScheduleTask{
{
StateID: 100,
Source: "event",
SourceID: 999,
Name: "高数",
Status: "existing",
CanEmbed: true,
Slots: []newagenttools.TaskSlot{{Day: 2, SlotStart: 3, SlotEnd: 4}},
EmbeddedBy: &[]int{1}[0],
},
{
StateID: 1,
Source: "task_item",
SourceID: 10,
Name: "复习线代",
Status: "existing",
Slots: []newagenttools.TaskSlot{{Day: 2, SlotStart: 3, SlotEnd: 4}},
EmbedHost: &hostStateID,
},
})
modified := buildTestState(days, []newagenttools.ScheduleTask{
{
StateID: 100,
Source: "event",
SourceID: 999,
Name: "高数",
Status: "existing",
CanEmbed: true,
Slots: []newagenttools.TaskSlot{{Day: 2, SlotStart: 3, SlotEnd: 4}},
},
{StateID: 1, Source: "task_item", SourceID: 10, Name: "复习线代", Status: "pending"},
})
changes := DiffScheduleState(original, modified)
var unplaceChange *ScheduleChange
for i := range changes {
if changes[i].SourceID == 10 {
unplaceChange = &changes[i]
}
}
if unplaceChange == nil {
t.Fatal("未找到 task_item 的 unplace 变更")
}
if unplaceChange.HostEventID != 999 {
t.Errorf("嵌入移除 HostEventID 应为 999实际 %d", unplaceChange.HostEventID)
}
}
// ==================== DiffScheduleState: task_item move ====================
// TestDiff_MoveTaskItem 验证task_item 移动时 OldHostEventID 和 HostEventID 分别对应旧/新位置宿主。
func TestDiff_MoveTaskItem_NonEmbedToNonEmbed(t *testing.T) {
days := defaultDays()
original := buildTestState(days, []newagenttools.ScheduleTask{
{
StateID: 1,
Source: "task_item",
SourceID: 10,
Name: "复习线代",
Status: "existing",
Slots: []newagenttools.TaskSlot{{Day: 1, SlotStart: 1, SlotEnd: 2}},
},
})
modified := buildTestState(days, []newagenttools.ScheduleTask{
{
StateID: 1,
Source: "task_item",
SourceID: 10,
Name: "复习线代",
Status: "existing",
Slots: []newagenttools.TaskSlot{{Day: 2, SlotStart: 5, SlotEnd: 6}},
},
})
changes := DiffScheduleState(original, modified)
if len(changes) != 1 {
t.Fatalf("期望 1 个变更,实际 %d", len(changes))
}
c := changes[0]
if c.Type != ChangeMove {
t.Errorf("期望 ChangeMove实际 %s", c.Type)
}
if c.HostEventID != 0 || c.OldHostEventID != 0 {
t.Errorf("非嵌入移动两个 HostEventID 均应为 0实际 %d/%d", c.OldHostEventID, c.HostEventID)
}
if len(c.OldCoords) != 2 || len(c.NewCoords) != 2 {
t.Errorf("旧坐标 %d 个,新坐标 %d 个,均期望 2 个", len(c.OldCoords), len(c.NewCoords))
}
}
// ==================== resolveHostEventID ====================
func TestResolveHostEventID_NoEmbed(t *testing.T) {
task := &newagenttools.ScheduleTask{StateID: 1, EmbedHost: nil}
state := buildTestState(defaultDays(), nil)
if got := resolveHostEventID(task, state); got != 0 {
t.Errorf("无嵌入时应返回 0实际 %d", got)
}
}
func TestResolveHostEventID_WithEmbed(t *testing.T) {
hostID := 100
task := &newagenttools.ScheduleTask{StateID: 1, EmbedHost: &hostID}
state := buildTestState(defaultDays(), []newagenttools.ScheduleTask{
{StateID: 100, Source: "event", SourceID: 999},
})
if got := resolveHostEventID(task, state); got != 999 {
t.Errorf("期望宿主 SourceID=999实际 %d", got)
}
}

View File

@@ -0,0 +1,477 @@
# 记忆模块实施计划(面试优先版 -> 产品可用版)
## 1. 文档目标
1. 在 3 天内交付一个“可演示、可讲清楚、可继续演进”的记忆系统 MVP。
2. 兼容当前单体工程,不引入高风险拆分,不破坏现有聊天主链路。
3. 复用现有 Outbox 异步基础设施,避免重复造轮子。
4. 形成可直接用于面试讲述的架构故事线、指标体系与演示脚本。
## 2. 背景与约束
1. 当前系统是单体 Go 项目,已有稳定的 `Outbox + Kafka + 消费事务` 通路。
2. 当前项目定位先是日程助手,长期演进为陪伴型助手。
3. 短期目标是快速做出“真的可用”的记忆能力,不追求一次做成完整通用平台。
4. 风险约束:
- 不能让重型 LLM 处理阻塞聊天实时响应。
- 不能在 Outbox 消费主循环里堆重计算,避免拖垮其他事件消费。
- 不能牺牲数据一致性与可审计性。
## 3. 总体方案
### 3.1 核心思路
采用“同步快路径 + 异步慢路径”:
1. 同步快路径:回复前快速读取可用记忆(以 MySQL 结构化事实为主),保证“下一轮能用”。
2. 异步慢路径:通过 Outbox 触发记忆抽取任务,执行去重、冲突消解、打分、向量化等重操作。
3. 读写解耦:写路径确保可靠入队,读路径优先稳定可控,再做语义增强。
### 3.2 存储职责分层
1. MySQL事实主库偏好、约束、任务上下文、TTL、置信度、敏感级别、来源
2. Milvus语义召回同义表达匹配、模糊语义联想
3. Redis可选热数据缓存后续优化不作为 MVP 必选项)。
### 3.3 编排层职责
`Memory Orchestrator` 负责两条链路:
1. 写入链路:候选抽取 -> 去重/冲突 -> 打分 -> 分流落库MySQL/Milvus
2. 读取链路:硬约束优先 -> 语义召回补充 -> 重排 -> 门控 -> 注入上下文。
## 4. 3 天执行计划(可直接照着做)
## Day 1把“可写入”打通可靠入队 + 可追踪)
### 目标
1. 记忆任务能稳定从聊天主链路发出。
2. 能看到任务从 `pending``success/failed` 的状态流转。
3. 保证失败可重试、可追踪、可补偿。
### 任务清单
1. 新增文档与目录占位:
- `backend/memory/README.md`(模块说明)
- `backend/memory/service/`(门面)
- `backend/memory/model/`DTO 与状态)
- `backend/memory/repo/`(数据访问)
- `backend/memory/orchestrator/`(编排)
- `backend/memory/worker/`(异步执行)
2. 新增 MySQL 表(建议先手写 SQL + DAO
- `memory_items`
- `memory_jobs`
- `memory_audit_logs`
- `memory_user_settings`
3. 新增 Outbox 事件:
- `memory.extract.requested`v1
4. 在聊天后置持久化环节发布事件:
- 仅传轻量字段,避免超大 payload。
5. 新增消费处理器:
- 只做任务入库,不做重型 LLM 调用。
6. 启动期接线:
-`backend/cmd/start.go` 注册记忆事件处理器。
### Day 1 验收标准
1. 一次聊天后Outbox 中能看到 `memory.extract.requested` 事件。
2. 事件消费后,`memory_jobs` 生成记录。
3. 人工触发 worker 可完成一次任务状态推进(哪怕先是 mock 抽取)。
## Day 2把“可读取可注入”打通先 MySQL 后向量)
### 目标
1. 记忆可在回复前被检索并注入上下文。
2. 能避免明显的“尬提”与无关提及。
3. 提供最小用户可控能力(查看/删除/关闭)。
### 任务清单
1. 实现 `MemoryReadService`
- 按用户与会话上下文读取记忆。
- 优先结构化硬约束(时间偏好、排程禁忌、显式偏好)。
2. 实现 `MemoryInjector`
- Top-K 记忆选择。
- token 预算截断。
- 注入模板统一化。
3. 实现门控逻辑:
- 相关性阈值。
- 置信度阈值。
- 时间衰减权重。
- 敏感级别检查。
4. 新增最小管理接口:
- `GET /api/v1/memory/items`
- `DELETE /api/v1/memory/items/:id`
- `POST /api/v1/memory/settings`(开关)
5. 完成首版日志埋点:
- 检索命中数、注入条数、门控丢弃原因。
### Day 2 验收标准
1. 给出偏好后,下一轮排程请求能利用该偏好。
2. 无关话题不会频繁硬提旧记忆。
3. 用户可删除指定记忆,删除后不再注入。
## Day 3把“可讲清楚”与“可评估”补齐面试可答
### 目标
1. 输出完整可讲架构,说明设计取舍。
2. 增加可量化指标,证明记忆“有用”而不是“看起来有”。
3. 可选接入 Milvus若环境未就绪先保留接口 + mock
### 任务清单
1. 实现/预留向量接口:
- `VectorStore.Upsert()`
- `VectorStore.Search()`
- `VectorStore.Delete()`
2. 对接 Milvus可选
- collection 初始化。
- 向量 + 元数据过滤检索。
3. 指标体系落地:
- 记忆命中率retrieved/useful
- 错误提及率wrong mention
- 用户纠正率user correction
- 回复延迟影响P50/P95
4. 准备演示脚本与面试问答稿:
- 5 分钟架构说明。
- 3 个典型失败案例及兜底策略。
- 未来迭代路线。
### Day 3 验收标准
1. 能现场演示“记住偏好 -> 下轮生效 -> 删除后失效”。
2. 能答清楚“为什么不是纯同步/纯异步”。
3. 能答清楚“为什么 MySQL + Milvus 双存储”。
## 5. 数据模型设计(首版)
## 5.1 `memory_items`(长期事实记忆)
用途:保存对业务有约束价值的可注入记忆。
关键字段建议:
1. `id` bigint PK
2. `user_id` bigint必填
3. `conversation_id` varchar(64)(可空,表示全局用户记忆)
4. `memory_type` varchar(32)
- `preference`(偏好)
- `constraint`(硬约束)
- `fact`(事实)
- `todo_hint`(近期提醒线索)
5. `title` varchar(128)
6. `content` text
7. `normalized_content` text去噪后
8. `confidence` decimal(5,4)0~1
9. `importance` decimal(5,4)0~1
10. `sensitivity_level` tinyint
- 0 普通
- 1 中敏
- 2 高敏
11. `source_message_id` bigint
12. `source_event_id` varchar(64)
13. `is_explicit` tinyint(1)(是否用户明确要求记住)
14. `status` varchar(16)
- `active`
- `archived`
- `deleted`
15. `ttl_at` datetime到期时间
16. `last_access_at` datetime
17. `created_at` datetime
18. `updated_at` datetime
索引建议:
1. `(user_id, status, memory_type, updated_at desc)`
2. `(user_id, conversation_id, status, updated_at desc)`
3. `(source_message_id)`(排查链路)
4. `(ttl_at)`(过期清理)
## 5.2 `memory_jobs`(异步任务队列表)
用途:承接 Outbox 消费后的待处理任务,解耦重计算。
关键字段建议:
1. `id` bigint PK
2. `user_id` bigint
3. `conversation_id` varchar(64)
4. `source_message_id` bigint
5. `source_event_id` varchar(64)
6. `job_type` varchar(32)
- `extract`
- `embed`
- `reconcile`
7. `payload_json` longtext
8. `status` varchar(16)
- `pending`
- `processing`
- `success`
- `failed`
- `dead`
9. `retry_count` int
10. `max_retry` int
11. `next_retry_at` datetime
12. `last_error` varchar(2000)
13. `created_at` datetime
14. `updated_at` datetime
索引建议:
1. `(status, next_retry_at, id)`
2. `(user_id, created_at desc)`
3. `(source_event_id)`(幂等与追踪)
## 5.3 `memory_audit_logs`(审计日志)
用途:回答“这条记忆是谁在什么条件下写的/改的/删的”。
关键字段建议:
1. `id` bigint PK
2. `memory_id` bigint
3. `user_id` bigint
4. `operation` varchar(32)
- `create`
- `update`
- `archive`
- `delete`
- `restore`
5. `operator_type` varchar(16)
- `system`
- `user`
6. `reason` varchar(255)
7. `before_json` longtext
8. `after_json` longtext
9. `created_at` datetime
## 5.4 `memory_user_settings`(用户记忆开关)
用途:实现用户可控能力。
关键字段建议:
1. `user_id` bigint PK
2. `memory_enabled` tinyint(1)
3. `implicit_memory_enabled` tinyint(1)
4. `sensitive_memory_enabled` tinyint(1)
5. `updated_at` datetime
## 6. 事件与协议设计
## 6.1 事件类型
1. `memory.extract.requested`v1
2. 预留:
- `memory.embed.requested`
- `memory.cleanup.requested`
## 6.2 载荷字段v1
1. `user_id`
2. `conversation_id`
3. `source_message_id`
4. `source_role`
5. `source_text`
6. `occurred_at`
7. `trace_id`
设计约束:
1. Payload 只放执行需要的最小字段。
2. 大文本允许截断并保留摘要,防止消息膨胀。
3. 必须包含幂等标识(如 `source_message_id + user_id`)。
## 7. 写入流程详细设计
## 7.1 主流程
1. 聊天主链路完成并落历史消息。
2. 发布 `memory.extract.requested` 到 Outbox。
3. Outbox 消费处理器验证 payload。
4. 处理器创建或幂等更新 `memory_jobs`(仅任务入库)。
5. `memory/worker` 扫描 `pending` 任务并抢占为 `processing`
6. Worker 调用 LLM 执行“候选记忆抽取”。
7. 执行标准化(时间归一化、实体归一化、噪声去除)。
8. 执行冲突消解(同类偏好最新优先、互斥约束降权)。
9. 计算分值(置信度、重要度、时效度)。
10. 写入 `memory_items` 与审计日志。
11. 触发向量化(同步或异步二选一)。
12. 成功后任务标记 `success`,失败按重试策略推进。
## 7.2 失败处理策略
1. Payload 非法:直接标记 dead不重试。
2. LLM 短时失败:指数退避重试。
3. DB 写失败:重试,超过上限 dead。
4. 向量写失败:
- MVP 策略:不阻塞事实写入,记录 `vector_pending` 状态。
- 后续策略:补偿任务重建向量索引。
## 7.3 幂等策略
1. 幂等键:`user_id + source_message_id + memory_type + normalized_content_hash`
2. 同幂等键重复写入:更新 `updated_at`、提升访问热度,不新增重复条目。
3. 由 Outbox 重试导致的重复消费必须无副作用。
## 8. 读取流程详细设计
## 8.1 主流程
1. 接收用户新问题,先做意图分类(排程/闲聊/混合)。
2.`memory_items` 拉取硬约束记忆(高优先级)。
3. 若 Milvus 可用,执行语义召回补充记忆候选。
4. 对候选执行重排:
- 相关性分
- 置信度分
- 时间衰减分
- 显式记忆加权
5. 执行门控:
- 低相关丢弃
- 高敏过滤
- 过期过滤
6. 按 token budget 选择最终注入条目。
7. 组装统一注入上下文,传给主模型生成回复。
## 8.2 重排评分(建议公式)
`final_score = 0.45 * relevance + 0.25 * confidence + 0.20 * recency + 0.10 * explicit_bonus`
说明:
1. 排程类场景可增加硬约束权重。
2. 闲聊类场景可提高语义相关权重。
3. 该公式为 MVP 默认值,后续可通过线上数据调参。
## 8.3 门控规则MVP
1. `final_score < 0.55` 不注入。
2. `sensitivity_level >= 2` 且用户未开启敏感记忆时不注入。
3. `ttl_at < now` 不注入。
4. 同主题最多注入 1~2 条,防止重复轰炸。
## 9. 对外接口MVP
## 9.1 用户接口
1. `GET /api/v1/memory/items`
- 支持按类型、时间、状态过滤。
2. `DELETE /api/v1/memory/items/:id`
- 软删除并写审计日志。
3. `POST /api/v1/memory/settings`
- 修改记忆总开关、隐式记忆开关。
## 9.2 内部接口
1. `MemoryService.EnqueueExtractJob(ctx, payload)`
2. `MemoryService.RetrieveForPrompt(ctx, req)`
3. `MemoryService.UpsertMemoryItems(ctx, items)`
4. `MemoryService.DeleteMemory(ctx, userID, memoryID)`
## 10. 可观测性与指标
## 10.1 指标定义
1. `memory_job_success_rate`
2. `memory_job_retry_rate`
3. `memory_retrieval_hit_rate`
4. `memory_injection_count_avg`
5. `memory_wrong_mention_rate`
6. `memory_user_correction_rate`
7. `chat_p95_latency_delta_with_memory`
## 10.2 日志与追踪
1. 每个任务写 `trace_id`,贯穿聊天请求 -> outbox -> memory_job -> memory_item。
2. 对门控丢弃记录原因码:
- `LOW_SCORE`
- `EXPIRED`
- `SENSITIVE_BLOCKED`
- `DUP_TOPIC`
3. 保证可以反查“为什么这次没有提某条记忆”。
## 11. 安全与隐私约束
1. 敏感信息默认不做隐式记忆(如健康、财务、证件等)。
2. 用户必须可删除历史记忆,删除后不再用于注入。
3. 记忆开关关闭后,仅保留必要系统数据,不再新增记忆条目。
4. 审计日志保留系统写入行为,便于风控与合规排查。
## 12. 测试策略
## 12.1 单元测试范围(实现阶段)
1. 候选抽取结果解析函数。
2. 冲突消解函数。
3. 重排评分函数。
4. 门控函数。
5. 幂等去重函数。
## 12.2 集成测试范围(实现阶段)
1. 聊天后事件成功入 outbox。
2. Outbox 消费后任务成功入 `memory_jobs`
3. Worker 成功写 `memory_items`
4. 读取链路能在回复中注入预期记忆。
## 12.3 注意事项(遵循项目约束)
1. 若编写 Go 测试文件(`*_test.go`)做验证,任务完成后按项目约定移除测试文件。
2. 每次执行本地 `go test` 后清理项目根目录 `.gocache`
## 13. 风险与回滚
## 13.1 主要风险
1. 记忆误提影响体验。
2. LLM 抽取不稳定导致脏记忆。
3. 向量检索误召回导致不相关注入。
4. 任务积压影响时效。
## 13.2 应对策略
1. 先严门控,宁可少提,不要乱提。
2. 保留“用户纠正”入口,纠正后提高冲突更新优先级。
3. 对召回做 metadata 过滤(近 30 天、类型限定)。
4. 监控任务积压长度,超阈值降级(停向量,仅结构化记忆)。
## 13.3 回滚方案
1. 配置开关 `memory.enabled=false` 可一键关闭记忆注入。
2. 保留写入链路但停读取链路,避免历史数据丢失。
3. 极端情况下停 worker仅保留主链路聊天功能。
## 14. 面试表达模板(可直接复述)
1. “我们做的是同步快路径 + 异步慢路径。同步保证下轮可用,异步负责治理和质量。”
2. “结构化事实放 MySQL 保证可控可审计,语义联想放 Milvus 提高召回覆盖。”
3. “Outbox 保证事件可靠入队Worker 解耦重计算,避免阻塞主链路。”
4. “我们用命中率、误提率、纠正率三项核心指标验证记忆是否真的有价值。”
## 15. DoD完成定义
1. 代码层:
- 记忆事件可发布、可消费、可重试。
- 记忆可检索、可注入、可删除、可关闭。
2. 质量层:
- 有基础指标与日志,支持问题排查。
- 有失败兜底与降级路径。
3. 叙事层:
- 3 分钟能讲清架构。
- 5 分钟能演示端到端效果。
- 能回答核心取舍与后续演进。
## 16. 本轮执行顺序建议
1. 先做 Day 1 的表结构与事件接线,不进入复杂抽取细节。
2. 再做 Day 2 的读取注入,优先 MySQL 结构化记忆。
3. 最后补 Day 3 的 Milvus 与指标,确保面试讲述闭环。
---
本文件定位为“落地执行蓝图”。后续每完成一块能力,建议在本文件追加“已落地清单 + 待办差距”,持续收敛为真实实施记录。

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
package conv
package newagentconv
import (
"context"
"fmt"
baseconv "github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/dao"
"github.com/LoveLosita/smartflow/backend/model"
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
@@ -141,7 +142,7 @@ func applyPlaceTaskItem(ctx context.Context, manager *dao.RepoManager, change Sc
}
} else {
// 普通路径:新建 ScheduleEvent + Schedule 记录
startTime, endTime, err := RelativeTimeToRealTime(week, dayOfWeek, startSection, endSection)
startTime, endTime, err := baseconv.RelativeTimeToRealTime(week, dayOfWeek, startSection, endSection)
if err != nil {
return fmt.Errorf("时间转换失败: %w", err)
}

View File

@@ -1,4 +1,4 @@
package conv
package newagentconv
import (
"fmt"

View File

@@ -1,4 +1,4 @@
package conv
package newagentconv
import (
"context"
@@ -6,6 +6,7 @@ import (
"sort"
"time"
baseconv "github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/dao"
"github.com/LoveLosita/smartflow/backend/model"
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
@@ -127,11 +128,11 @@ func buildWindowFromTaskClasses(taskClasses []model.TaskClass) (windowDays []Win
if tc.StartDate == nil || tc.EndDate == nil || tc.EndDate.Before(*tc.StartDate) {
continue
}
startWeek, startDay, err := RealDateToRelativeDate(tc.StartDate.Format(DateFormat))
startWeek, startDay, err := baseconv.RealDateToRelativeDate(tc.StartDate.Format(baseconv.DateFormat))
if err != nil {
continue
}
endWeek, endDay, err := RealDateToRelativeDate(tc.EndDate.Format(DateFormat))
endWeek, endDay, err := baseconv.RealDateToRelativeDate(tc.EndDate.Format(baseconv.DateFormat))
if err != nil {
continue
}
@@ -176,7 +177,7 @@ func buildWindowFromTaskClasses(taskClasses []model.TaskClass) (windowDays []Win
// buildCurrentWeekWindow 构造“当前周 7 天”的兜底窗口。
func buildCurrentWeekWindow() (windowDays []WindowDay, weeks []int, err error) {
now := time.Now()
currentWeek, _, err := RealDateToRelativeDate(now.Format(DateFormat))
currentWeek, _, err := baseconv.RealDateToRelativeDate(now.Format(baseconv.DateFormat))
if err != nil {
return nil, nil, fmt.Errorf("解析当前日期失败: %w", err)
}

View File

@@ -1,4 +1,4 @@
package conv
package newagentconv
import (
"sort"

View File

@@ -83,24 +83,28 @@ func RunAgentGraph(ctx context.Context, input newagentmodel.AgentGraphRunInput)
)); err != nil {
return nil, err
}
// Plan -> Plan(继续规划) / Confirm(规划完成) / Interrupt(需要追问用户)
// Plan -> Plan(继续规划) / Confirm(规划完成) / RoughBuild(需粗排) / Execute(直接执行) / Deliver(完成) / Interrupt(需要追问用户)
if err := g.AddBranch(NodePlan, compose.NewGraphBranch(
branchAfterPlan,
map[string]bool{
NodePlan: true,
NodeConfirm: true,
NodeInterrupt: true,
NodePlan: true,
NodeConfirm: true,
NodeRoughBuild: true,
NodeExecute: true,
NodeDeliver: true,
NodeInterrupt: true,
},
)); err != nil {
return nil, err
}
// Confirm -> Plan(用户拒绝或重规划) / RoughBuild(需粗排) / Execute(直接执行) / Interrupt(等待用户确认)
// Confirm -> Plan(用户拒绝或重规划) / RoughBuild(需粗排) / Execute(直接执行) / Deliver(完成) / Interrupt(等待用户确认)
if err := g.AddBranch(NodeConfirm, compose.NewGraphBranch(
branchAfterConfirm,
map[string]bool{
NodePlan: true,
NodeRoughBuild: true,
NodeExecute: true,
NodeDeliver: true,
NodeInterrupt: true,
},
)); err != nil {

View File

@@ -57,7 +57,7 @@ func (o *FlowTerminalOutcome) Normalize() {
o.InternalReason = strings.TrimSpace(o.InternalReason)
}
const DefaultMaxRounds = 30
const DefaultMaxRounds = 60
// CommonState 承载可持久化的主流程状态。
//

View File

@@ -19,6 +19,11 @@ const (
chatStageName = "chat"
chatStatusBlockID = "chat.status"
chatSpeakBlockID = "chat.speak"
// chatHistoryKindKey 用于在 history 中打运行态标记,供 prompt 层做上下文分层。
chatHistoryKindKey = "newagent_history_kind"
// chatHistoryKindExecuteLoopClosed 表示“上一轮 execute loop 已正常收口”。
// prompt 侧会据此把旧 loop 归档到 msg1而不是继续占用 msg2 窗口。
chatHistoryKindExecuteLoopClosed = "execute_loop_closed"
)
type reorderPreference int
@@ -70,6 +75,12 @@ func RunChatNode(ctx context.Context, input ChatNodeInput) error {
if !runtimeState.HasPendingInteraction() && flowState.Phase == newagentmodel.PhaseDone {
terminalBefore := flowState.TerminalStatus()
roundBefore := flowState.RoundUsed
// 1. 只有“正常完成(completed)”才打 loop 收口标记:
// 1.1 这样下一轮进入 execute 时msg2 会只保留“当前活跃循环”窗口;
// 1.2 异常收口exhausted/aborted不打标记允许后续“继续”时沿用上一轮 loop 轨迹。
if terminalBefore == newagentmodel.FlowTerminalStatusCompleted {
appendExecuteLoopClosedMarker(conversationContext)
}
flowState.ResetForNextRun()
log.Printf(
"[DEBUG] chat reset runtime for next run chat=%s round_before=%d terminal_before=%s",
@@ -139,6 +150,45 @@ func RunChatNode(ctx context.Context, input ChatNodeInput) error {
}
}
// appendExecuteLoopClosedMarker 在 history 中写入“execute loop 已正常收口”标记。
//
// 职责边界:
// 1. 只负责写一个轻量 marker供 prompt 分层;
// 2. 不负责历史裁剪,不负责消息摘要;
// 3. 若末尾已经是同类 marker则幂等跳过避免重复写入。
func appendExecuteLoopClosedMarker(conversationContext *newagentmodel.ConversationContext) {
if conversationContext == nil {
return
}
history := conversationContext.HistorySnapshot()
if len(history) > 0 {
last := history[len(history)-1]
if isExecuteLoopClosedMarker(last) {
return
}
}
conversationContext.AppendHistory(&schema.Message{
Role: schema.Assistant,
Content: "",
Extra: map[string]any{
chatHistoryKindKey: chatHistoryKindExecuteLoopClosed,
},
})
}
func isExecuteLoopClosedMarker(msg *schema.Message) bool {
if msg == nil || msg.Extra == nil {
return false
}
kind, ok := msg.Extra[chatHistoryKindKey].(string)
if !ok {
return false
}
return strings.TrimSpace(kind) == chatHistoryKindExecuteLoopClosed
}
// handleDirectReply 处理简单任务:直接输出回复。
func handleDirectReply(
ctx context.Context,

View File

@@ -130,6 +130,10 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
flowState.SuggestedOrderBaseline = buildSuggestedOrderSnapshot(input.ScheduleState)
}
// 1. 每轮 execute 开始前先刷新一次执行锚点,避免 LLM 继续读取旧的当前步骤。
// 2. 这里仅维护上下文一致性,不改变流程状态。
syncExecutePinnedContext(conversationContext, flowState)
// 2. 推送执行阶段状态,让前端知道当前进度。
if flowState.HasCurrentPlanStep() {
// 有 plan显示步骤进度。
@@ -400,6 +404,9 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
// 所有步骤已完成,进入交付阶段。
flowState.Done()
}
// 1. next_plan 推进后立刻刷新 current_step / execution_context。
// 2. 若计划已结束,这里会移除 current_step避免下轮读取到旧步骤。
syncExecutePinnedContext(conversationContext, flowState)
return nil
case newagentmodel.ExecuteActionDone:
@@ -462,6 +469,114 @@ func prepareExecuteNodeInput(input ExecuteNodeInput) (*newagentmodel.AgentRuntim
// 1. 优先使用 LLM 输出的 speak
// 2. 其次使用 reason
// 3. 最后使用默认文案。
// syncExecutePinnedContext 同步 execute 阶段的置顶上下文。
//
// 步骤说明:
// 1. 每轮先刷新 execution_context确保模型始终看到最新执行锚点。
// 2. 若当前仍在计划执行且 current_step 可读,则覆盖 current_step 置顶块。
// 3. 若计划已执行完或当前步骤不可读,则移除 current_step避免模型误读旧步骤。
func syncExecutePinnedContext(
conversationContext *newagentmodel.ConversationContext,
flowState *newagentmodel.CommonState,
) {
if conversationContext == nil || flowState == nil {
return
}
execContent := buildExecuteContextPinnedMarkdown(flowState)
if strings.TrimSpace(execContent) != "" {
conversationContext.UpsertPinnedBlock(newagentmodel.ContextBlock{
Key: executePinnedKey,
Title: "执行上下文",
Content: execContent,
})
}
if !flowState.HasPlan() {
conversationContext.RemovePinnedBlock(planCurrentStepKey)
return
}
step, ok := flowState.CurrentPlanStep()
if !ok {
conversationContext.RemovePinnedBlock(planCurrentStepKey)
return
}
current, total := flowState.PlanProgress()
title := strings.TrimSpace(planCurrentStepTitle)
if title == "" {
title = "当前步骤"
}
conversationContext.UpsertPinnedBlock(newagentmodel.ContextBlock{
Key: planCurrentStepKey,
Title: title,
Content: buildCurrentPlanStepPinnedMarkdown(step, current, total),
})
}
// buildExecuteContextPinnedMarkdown 构造 execute 节点给模型的执行锚点文本。
func buildExecuteContextPinnedMarkdown(flowState *newagentmodel.CommonState) string {
if flowState == nil {
return ""
}
lines := make([]string, 0, 8)
if flowState.HasPlan() {
lines = append(lines, "执行模式:计划执行(按步骤推进)")
current, total := flowState.PlanProgress()
lines = append(lines, fmt.Sprintf("计划进度:第 %d/%d 步", current, total))
if step, ok := flowState.CurrentPlanStep(); ok {
lines = append(lines, "当前步骤:"+compactExecutePinnedText(step.Content))
doneWhen := compactExecutePinnedText(step.DoneWhen)
if doneWhen != "" {
lines = append(lines, "完成判定(done_when)"+doneWhen)
}
lines = append(lines, "动作纪律:未满足 done_when 禁止 next_plan满足后优先 next_plan。")
} else {
lines = append(lines, "当前步骤:不可读(可能已执行完成)")
}
} else {
lines = append(lines, "执行模式:自由执行(无预定义步骤)")
}
if flowState.MaxRounds > 0 {
lines = append(lines, fmt.Sprintf("轮次预算:%d/%d", flowState.RoundUsed, flowState.MaxRounds))
}
return strings.TrimSpace(strings.Join(lines, "\n"))
}
// buildCurrentPlanStepPinnedMarkdown 构造 current_step 置顶块内容。
func buildCurrentPlanStepPinnedMarkdown(step newagentmodel.PlanStep, current, total int) string {
lines := make([]string, 0, 4)
lines = append(lines, fmt.Sprintf("步骤进度:第 %d/%d 步", current, total))
content := compactExecutePinnedText(step.Content)
if content == "" {
content = "(空)"
}
lines = append(lines, "步骤内容:"+content)
doneWhen := compactExecutePinnedText(step.DoneWhen)
if doneWhen != "" {
lines = append(lines, "完成判定:"+doneWhen)
}
return strings.TrimSpace(strings.Join(lines, "\n"))
}
// compactExecutePinnedText 把多行文本压成单行,避免置顶块出现冗长换行噪音。
func compactExecutePinnedText(text string) string {
text = strings.TrimSpace(text)
if text == "" {
return ""
}
text = strings.ReplaceAll(text, "\r\n", "\n")
text = strings.ReplaceAll(text, "\n", "")
return strings.TrimSpace(text)
}
func resolveExecuteAskUserText(decision *newagentmodel.ExecuteDecision) string {
if decision == nil {
return "执行过程中遇到不确定的情况,需要向你确认。"

View File

@@ -19,6 +19,7 @@ const (
planStageName = "plan"
planStatusBlockID = "plan.status"
planSpeakBlockID = "plan.speak"
planSummaryBlockID = "plan.summary"
planPinnedKey = "current_plan"
planCurrentStepKey = "current_step"
planCurrentStepTitle = "当前步骤"
@@ -170,6 +171,23 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
// always_execute 开启时,计划层跳过确认闸门,直接进入执行阶段。
// 这样可以与 Execute 节点的“写工具跳过确认”语义保持一致。
if input.AlwaysExecute {
// 1. 自动执行模式不会经过 Confirm 卡片,因此这里先把完整计划明确展示给用户。
// 2. 摘要格式复用 Confirm 节点,保证“手动确认”和“自动执行”两条链路文案一致。
// 3. 推流后同步写入历史,确保后续 Execute 阶段的上下文也能看到这份计划。
summary := strings.TrimSpace(buildPlanSummary(decision.PlanSteps))
if summary != "" {
if err := emitter.EmitPseudoAssistantText(
ctx,
planSummaryBlockID,
planStageName,
summary,
newagentstream.DefaultPseudoStreamOptions(),
); err != nil {
return fmt.Errorf("自动执行前计划摘要推送失败: %w", err)
}
conversationContext.AppendHistory(schema.AssistantMessage(summary, nil))
}
flowState.ConfirmPlan()
_ = emitter.EmitStatus(
planStatusBlockID,

View File

@@ -320,7 +320,7 @@ func BuildExecuteMessages(state *newagentmodel.CommonState, ctx *newagentmodel.C
BuildExecuteSystemPrompt(),
state,
ctx,
buildExecuteStrictJSONUserPrompt(),
buildExecuteStrictJSONUserPromptWithPlan(state),
)
}
@@ -332,6 +332,50 @@ func BuildExecuteMessages(state *newagentmodel.CommonState, ctx *newagentmodel.C
)
}
// buildExecuteStrictJSONUserPromptWithPlan 在通用 JSON 约束上补充“当前计划步骤”强约束。
//
// 职责边界:
// 1. 负责把“当前是第几步、当前步骤内容、done_when 判定”明确写进用户指令;
// 2. 不负责替代系统提示词中的工具规则和安全边界;
// 3. 当 state 无法提供有效当前步骤时,仅追加兜底提示,不在此处推进流程状态。
func buildExecuteStrictJSONUserPromptWithPlan(state *newagentmodel.CommonState) string {
base := buildExecuteStrictJSONUserPrompt()
if state == nil || !state.HasPlan() {
return base
}
current, total := state.PlanProgress()
step, ok := state.CurrentPlanStep()
if !ok {
return strings.TrimSpace(base + `
计划步骤强约束:
- 当前没有可执行的计划步骤,请先基于已有事实检查是否已完成全部计划。
- 若全部计划已完成:输出 action=done并在 goal_check 总结完成证据。
- 若未完成但缺少关键信息:输出 action=ask_user。`)
}
stepContent := strings.TrimSpace(step.Content)
if stepContent == "" {
stepContent = "(当前步骤内容为空,以 done_when 为准)"
}
doneWhen := strings.TrimSpace(step.DoneWhen)
if doneWhen == "" {
doneWhen = "(未提供 done_when需基于当前步骤目标给出可验证完成证据"
}
return strings.TrimSpace(fmt.Sprintf(`%s
计划步骤强约束:
- 你当前只允许推进第 %d/%d 步。
- 当前步骤内容:%s
- 当前步骤完成判定(done_when)%s
- 未满足 done_when 时:只能输出 continue / confirm / ask_user禁止输出 next_plan。
- 满足 done_when 时:优先输出 action=next_plan并在 goal_check 逐条对照 done_when 给出证据。
- 禁止跳步:不要提前执行后续步骤。`,
base, current, total, stepContent, doneWhen))
}
// buildExecutePromptWithFormatGuard 统一补一层更硬的 JSON 输出约束。
func buildExecutePromptWithFormatGuard(base string) string {
base = strings.TrimSpace(base)

View File

@@ -14,6 +14,7 @@ import (
const (
executeHistoryKindKey = "newagent_history_kind"
executeHistoryKindCorrectionUser = "llm_correction_prompt"
executeHistoryKindLoopClosed = "execute_loop_closed"
// executeLoopWindowLimit 控制“当轮 ReAct Loop 窗口”最多保留多少条记录。
// 采用固定窗口能避免上下文无上限增长,且可保持“最近行为”可追踪。
@@ -36,6 +37,8 @@ type executeLoopRecord struct {
Observation string
}
const executeMessage1MaxRunes = 1400
// buildExecuteStageMessages 组装 execute 阶段 4 条消息骨架。
//
// 消息结构(固定):
@@ -50,8 +53,8 @@ func buildExecuteStageMessages(
runtimeUserPrompt string,
) []*schema.Message {
msg0 := buildExecuteMessage0(stageSystemPrompt, ctx)
msg1 := buildExecuteMessage1(ctx)
msg2 := buildExecuteMessage2(ctx)
msg1 := buildExecuteMessage1V3(ctx)
msg2 := buildExecuteMessage2V3(ctx)
msg3 := buildExecuteMessage3(state, ctx, runtimeUserPrompt)
return []*schema.Message{
@@ -140,6 +143,209 @@ func buildExecuteMessage2(ctx *newagentmodel.ConversationContext) string {
}
// buildExecuteMessage3 生成当前执行状态与执行锚点。
// buildExecuteMessage1V2 生成历史摘要:
// 1. 已收口的 loop 归档到 msg1
// 2. 当前活跃 loop 只保留“早期摘要”;
// 3. 最终对 msg1 做统一长度裁剪,控制 token 开销。
func buildExecuteMessage1V2(ctx *newagentmodel.ConversationContext) string {
lines := []string{"历史上下文(仅供参考):"}
if ctx == nil {
lines = append(lines,
"- 用户目标:暂无可用历史输入。",
"- 阶段锚点:按当前工具事实推进执行。",
"- 历史归档 ReAct 摘要:暂无。",
"- 当前循环早期摘要:暂无。",
)
return trimExecuteMessage1ByBudget(strings.Join(lines, "\n"))
}
history := ctx.HistorySnapshot()
firstUser, lastUser := pickExecuteUserInputs(history)
switch {
case firstUser == "":
lines = append(lines, "- 用户目标:暂无可用历史输入。")
case lastUser != "" && lastUser != firstUser:
lines = append(lines, "- 用户目标:"+firstUser+";最近补充:"+lastUser)
default:
lines = append(lines, "- 用户目标:"+firstUser)
}
if hasExecuteRoughBuildDone(ctx) {
lines = append(lines, "- 阶段锚点:粗排已完成,本轮仅做微调,不重新 place。")
} else {
lines = append(lines, "- 阶段锚点:按当前工具事实推进,不做无依据操作。")
}
archivedLoops, activeLoops := splitExecuteLoopRecordsByBoundary(history)
lines = append(lines, "- 历史归档 ReAct 摘要:"+buildEarlyExecuteReactSummary(archivedLoops, 0))
lines = append(lines, "- 当前循环早期摘要:"+buildEarlyExecuteReactSummary(activeLoops, executeLoopWindowLimit))
return trimExecuteMessage1ByBudget(strings.Join(lines, "\n"))
}
// buildExecuteMessage2V2 仅展示“当前活跃 loop”的窗口记录。
func buildExecuteMessage2V2(ctx *newagentmodel.ConversationContext) string {
lines := []string{"当轮 ReAct Loop 记录(窗口):"}
if ctx == nil {
lines = append(lines, "- 暂无可用 ReAct 记录。")
return strings.Join(lines, "\n")
}
_, activeLoops := splitExecuteLoopRecordsByBoundary(ctx.HistorySnapshot())
if len(activeLoops) == 0 {
lines = append(lines, "- 暂无可用 ReAct 记录。")
return strings.Join(lines, "\n")
}
windowLoops := tailExecuteLoops(activeLoops, executeLoopWindowLimit)
windowLoops = compressExecuteLoopObservationsByTool(windowLoops)
for i, loop := range windowLoops {
lines = append(lines, fmt.Sprintf("%d) thought/reason%s", i+1, loop.Thought))
lines = append(lines, fmt.Sprintf(" tool_call%s", renderExecuteToolCallText(loop.ToolName, loop.ToolArgs)))
lines = append(lines, fmt.Sprintf(" observation%s", loop.Observation))
}
return strings.Join(lines, "\n")
}
// splitExecuteLoopRecordsByBoundary 按“已收口标记”拆分归档/活跃 ReAct 记录。
//
// 规则:
// 1. 标记之前的记录归档到 msg1
// 2. 标记之后的记录作为活跃 loop 进入 msg2
// 3. 若没有标记,则全部视为活跃记录(兼容旧会话快照)。
func splitExecuteLoopRecordsByBoundary(history []*schema.Message) (archived []executeLoopRecord, active []executeLoopRecord) {
if len(history) == 0 {
return nil, nil
}
boundary := findLatestExecuteLoopClosedMarker(history)
if boundary < 0 {
return nil, collectExecuteLoopRecords(history)
}
if boundary > 0 {
archived = collectExecuteLoopRecords(history[:boundary])
}
if boundary+1 < len(history) {
active = collectExecuteLoopRecords(history[boundary+1:])
}
return archived, active
}
func findLatestExecuteLoopClosedMarker(history []*schema.Message) int {
for i := len(history) - 1; i >= 0; i-- {
msg := history[i]
if msg == nil || msg.Extra == nil {
continue
}
kind, ok := msg.Extra[executeHistoryKindKey].(string)
if !ok {
continue
}
if strings.TrimSpace(kind) == executeHistoryKindLoopClosed {
return i
}
}
return -1
}
func trimExecuteMessage1ByBudget(content string) string {
content = strings.TrimSpace(content)
if content == "" {
return ""
}
runes := []rune(content)
if len(runes) <= executeMessage1MaxRunes {
return content
}
if executeMessage1MaxRunes <= 3 {
return string(runes[:executeMessage1MaxRunes])
}
return string(runes[:executeMessage1MaxRunes-3]) + "..."
}
// buildExecuteMessage1V3 负责把“上一轮 loop 归档”并入 msg1并统一做长度裁剪。
func buildExecuteMessage1V3(ctx *newagentmodel.ConversationContext) string {
lines := []string{"历史上下文(仅供参考):"}
if ctx == nil {
lines = append(lines,
"- 用户目标:暂无可用历史输入。",
"- 阶段锚点:按当前工具事实推进执行。",
"- 历史归档 ReAct 摘要:暂无。",
"- 历史归档 ReAct 窗口:暂无。",
"- 当前循环早期摘要:暂无。",
)
return trimExecuteMessage1ByBudget(strings.Join(lines, "\n"))
}
history := ctx.HistorySnapshot()
firstUser, lastUser := pickExecuteUserInputs(history)
switch {
case firstUser == "":
lines = append(lines, "- 用户目标:暂无可用历史输入。")
case lastUser != "" && lastUser != firstUser:
lines = append(lines, "- 用户目标:"+firstUser+";最近补充:"+lastUser)
default:
lines = append(lines, "- 用户目标:"+firstUser)
}
if hasExecuteRoughBuildDone(ctx) {
lines = append(lines, "- 阶段锚点:粗排已完成,本轮仅做微调,不重新 place。")
} else {
lines = append(lines, "- 阶段锚点:按当前工具事实推进,不做无依据操作。")
}
// 1. 通过收口标记拆分“归档 loop / 当前活跃 loop”。
// 2. 归档 loop 的窗口条目直接并入 msg1满足“上一轮 msg2 挪入 msg1”。
// 3. 当前活跃 loop 在 msg1 只保留早期摘要,详细窗口交给 msg2。
archivedLoops, activeLoops := splitExecuteLoopRecordsByBoundary(history)
lines = append(lines, "- 历史归档 ReAct 摘要:"+buildEarlyExecuteReactSummary(archivedLoops, executeLoopWindowLimit))
lines = append(lines, renderArchivedExecuteLoopWindowForMessage1V3(archivedLoops))
lines = append(lines, "- 当前循环早期摘要:"+buildEarlyExecuteReactSummary(activeLoops, executeLoopWindowLimit))
return trimExecuteMessage1ByBudget(strings.Join(lines, "\n"))
}
// buildExecuteMessage2V3 仅承载“当前活跃 loop”的窗口。
// 若是新一轮刚开始(活跃 loop 为空),明确返回“已清空”状态。
func buildExecuteMessage2V3(ctx *newagentmodel.ConversationContext) string {
lines := []string{"当轮 ReAct Loop 记录(窗口):"}
if ctx == nil {
lines = append(lines, "- 暂无可用 ReAct 记录。")
return strings.Join(lines, "\n")
}
_, activeLoops := splitExecuteLoopRecordsByBoundary(ctx.HistorySnapshot())
if len(activeLoops) == 0 {
lines = append(lines, "- 已清空(新一轮 loop 准备中)。")
return strings.Join(lines, "\n")
}
windowLoops := tailExecuteLoops(activeLoops, executeLoopWindowLimit)
windowLoops = compressExecuteLoopObservationsByTool(windowLoops)
for i, loop := range windowLoops {
lines = append(lines, fmt.Sprintf("%d) thought/reason%s", i+1, loop.Thought))
lines = append(lines, fmt.Sprintf(" tool_call%s", renderExecuteToolCallText(loop.ToolName, loop.ToolArgs)))
lines = append(lines, fmt.Sprintf(" observation%s", loop.Observation))
}
return strings.Join(lines, "\n")
}
func renderArchivedExecuteLoopWindowForMessage1V3(records []executeLoopRecord) string {
if len(records) == 0 {
return "- 历史归档 ReAct 窗口:暂无。"
}
windowLoops := tailExecuteLoops(records, executeLoopWindowLimit)
windowLoops = compressExecuteLoopObservationsByTool(windowLoops)
lines := []string{"历史归档 ReAct 窗口(由上一轮 msg2 并入):"}
for i, loop := range windowLoops {
lines = append(lines, fmt.Sprintf("%d) thought/reason%s", i+1, loop.Thought))
lines = append(lines, fmt.Sprintf(" tool_call%s", renderExecuteToolCallText(loop.ToolName, loop.ToolArgs)))
lines = append(lines, fmt.Sprintf(" observation%s", loop.Observation))
}
return strings.Join(lines, "\n")
}
func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext, runtimeUserPrompt string) string {
lines := []string{"当前执行状态:"}
@@ -169,6 +375,32 @@ func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.C
if initialGoal != "" && initialGoal != currentGoal {
lines = append(lines, "- 首轮目标来源:"+initialGoal)
}
// 1. 有 plan 时,把当前步骤与完成判定强制写入 msg3。
// 2. 该锚点用于约束模型“只推进当前步骤”,避免退化成泛化 ReAct。
// 3. 当前步骤不可读时给出兜底指引,避免引用旧步骤。
if state != nil && state.HasPlan() {
current, total := state.PlanProgress()
lines = append(lines, "计划步骤锚点(强约束):")
if step, ok := state.CurrentPlanStep(); ok {
stepContent := strings.TrimSpace(step.Content)
if stepContent == "" {
stepContent = "(当前步骤内容为空)"
}
doneWhen := strings.TrimSpace(step.DoneWhen)
if doneWhen == "" {
doneWhen = "(未提供 done_when需基于步骤目标给出可验证完成证据"
}
lines = append(lines, fmt.Sprintf("- 当前步骤:第 %d/%d 步", current, total))
lines = append(lines, "- 当前步骤内容:"+stepContent)
lines = append(lines, "- 当前步骤完成判定(done_when)"+doneWhen)
lines = append(lines, "- 动作纪律1未满足 done_when 时,只能 continue / confirm / ask_user禁止 next_plan")
lines = append(lines, "- 动作纪律2满足 done_when 时,优先 next_plan并在 goal_check 对照 done_when 给证据")
lines = append(lines, "- 动作纪律3禁止跳到后续步骤执行")
} else {
lines = append(lines, "- 当前计划步骤不可读;请先判断是否已完成全部计划")
lines = append(lines, "- 若已完成全部计划,输出 done 并给出 goal_check 证据")
}
}
if taskClassText := renderExecuteTaskClassIDs(state); taskClassText != "" {
lines = append(lines, "- 目标任务类:"+taskClassText)
}

View File

@@ -5,6 +5,8 @@ import (
"fmt"
"sort"
"strings"
compositelogic "github.com/LoveLosita/smartflow/backend/logic"
)
// minContextSnapshot 记录任务在复合重排前后的最小快照,用于输出摘要。
@@ -27,26 +29,36 @@ type refineTaskCandidate struct {
OriginRank int
}
// refineSlotCandidate 是复合规划器使用的候选坑位输入
type refineSlotCandidate struct {
Week int
DayOfWeek int
SectionFrom int
SectionTo int
// compositeIDMapper 负责维护 state_id 与 logic 规划入参 ID 的双向映射
//
// 说明:
// 1. 当前阶段使用等值映射logicID=stateID保证行为不变
// 2. 保留独立适配层,后续若切到真实 task_item_id只需改这里
// 3. 通过双向映射保证“入参转换 + 结果回填”一致。
type compositeIDMapper struct {
stateToLogic map[int]int
logicToState map[int]int
}
// refineMovePlanItem 是规划器输出的一条移动方案
type refineMovePlanItem struct {
TaskID int
ToWeek int
ToDay int
ToSectionFrom int
ToSectionTo int
}
// refinePlanOptions 是复合规划器的可选参数。
type refinePlanOptions struct {
ExistingDayLoad map[string]int
// buildCompositeIDMapper 构建并校验本轮复合工具的 ID 映射
func buildCompositeIDMapper(stateIDs []int) (*compositeIDMapper, error) {
mapper := &compositeIDMapper{
stateToLogic: make(map[int]int, len(stateIDs)),
logicToState: make(map[int]int, len(stateIDs)),
}
for _, stateID := range stateIDs {
if stateID <= 0 {
return nil, fmt.Errorf("存在非法 state_id=%d", stateID)
}
if _, exists := mapper.stateToLogic[stateID]; exists {
return nil, fmt.Errorf("state_id=%d 重复", stateID)
}
// 当前迁移阶段采用等值映射,先把“映射机制”跑通。
logicID := stateID
mapper.stateToLogic[stateID] = logicID
mapper.logicToState[logicID] = stateID
}
return mapper, nil
}
// MinContextSwitch 在给定任务集合内重排 suggested 任务,尽量减少上下文切换次数。
@@ -61,20 +73,24 @@ func MinContextSwitch(state *ScheduleState, taskIDs []int) string {
}
// 1. 收集任务并做前置校验,确保规划输入可用。
plannerTasks, beforeByID, excludeIDs, err := collectCompositePlannerTasks(state, taskIDs, "减少上下文切换")
plannerTasks, beforeByID, excludeIDs, idMapper, err := collectCompositePlannerTasks(state, taskIDs, "减少上下文切换")
if err != nil {
return err.Error()
}
logicTasks, err := toLogicPlannerTasks(plannerTasks, idMapper)
if err != nil {
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
}
// 2. 该工具固定在“当前任务已占坑位集合”内重排,不向外扩张候选位。
currentSlots := buildCurrentSlotsFromPlannerTasks(plannerTasks)
plannedMoves, err := planMinContextSwitchMoves(plannerTasks, currentSlots, refinePlanOptions{})
currentSlots := buildCurrentSlotsFromPlannerTasks(logicTasks)
plannedMoves, err := compositelogic.PlanMinContextSwitchMoves(logicTasks, currentSlots, compositelogic.RefineCompositePlanOptions{})
if err != nil {
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
}
// 3. 映射回工具态坐标并在提交前做完整校验。
afterByID, err := buildAfterSnapshotsFromPlannedMoves(state, beforeByID, plannedMoves)
afterByID, err := buildAfterSnapshotsFromPlannedMoves(state, beforeByID, plannedMoves, idMapper)
if err != nil {
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
}
@@ -163,14 +179,18 @@ func SpreadEven(state *ScheduleState, taskIDs []int, args map[string]any) string
}
// 1. 先做任务侧校验,避免后续规划在脏输入上执行。
plannerTasks, beforeByID, excludeIDs, err := collectCompositePlannerTasks(state, taskIDs, "均匀化调整")
plannerTasks, beforeByID, excludeIDs, idMapper, err := collectCompositePlannerTasks(state, taskIDs, "均匀化调整")
if err != nil {
return err.Error()
}
logicTasks, err := toLogicPlannerTasks(plannerTasks, idMapper)
if err != nil {
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
}
// 2. 按跨度需求收集候选坑位,确保每类跨度都有可用池。
spanNeed := make(map[int]int, len(plannerTasks))
for _, task := range plannerTasks {
spanNeed := make(map[int]int, len(logicTasks))
for _, task := range logicTasks {
spanNeed[task.SectionTo-task.SectionFrom+1]++
}
candidateSlots, err := collectSpreadEvenCandidateSlotsBySpan(state, args, spanNeed)
@@ -180,7 +200,7 @@ func SpreadEven(state *ScheduleState, taskIDs []int, args map[string]any) string
// 3. 用“范围内既有负载”作为打分基线,让结果更接近均匀分布。
dayLoadBaseline := buildSpreadEvenDayLoadBaseline(state, excludeIDs, candidateSlots)
plannedMoves, err := planEvenSpreadMoves(plannerTasks, candidateSlots, refinePlanOptions{
plannedMoves, err := compositelogic.PlanEvenSpreadMoves(logicTasks, candidateSlots, compositelogic.RefineCompositePlanOptions{
ExistingDayLoad: dayLoadBaseline,
})
if err != nil {
@@ -188,7 +208,7 @@ func SpreadEven(state *ScheduleState, taskIDs []int, args map[string]any) string
}
// 4. 回填 + 校验 + 原子提交。
afterByID, err := buildAfterSnapshotsFromPlannedMoves(state, beforeByID, plannedMoves)
afterByID, err := buildAfterSnapshotsFromPlannedMoves(state, beforeByID, plannedMoves, idMapper)
if err != nil {
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
}
@@ -283,10 +303,15 @@ func collectCompositePlannerTasks(
state *ScheduleState,
taskIDs []int,
toolLabel string,
) ([]refineTaskCandidate, map[int]minContextSnapshot, []int, error) {
) ([]refineTaskCandidate, map[int]minContextSnapshot, []int, *compositeIDMapper, error) {
normalizedIDs := uniquePositiveInts(taskIDs)
if len(normalizedIDs) < 2 {
return nil, nil, nil, fmt.Errorf("%s失败task_ids 至少需要 2 个有效任务 ID", toolLabel)
return nil, nil, nil, nil, fmt.Errorf("%s失败task_ids 至少需要 2 个有效任务 ID", toolLabel)
}
idMapper, err := buildCompositeIDMapper(normalizedIDs)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("%s失败ID 映射构建失败:%s", toolLabel, err.Error())
}
plannerTasks := make([]refineTaskCandidate, 0, len(normalizedIDs))
@@ -296,28 +321,28 @@ func collectCompositePlannerTasks(
for rank, taskID := range normalizedIDs {
task := state.TaskByStateID(taskID)
if task == nil {
return nil, nil, nil, fmt.Errorf("%s失败任务ID %d 不存在", toolLabel, taskID)
return nil, nil, nil, nil, fmt.Errorf("%s失败任务ID %d 不存在", toolLabel, taskID)
}
if !IsSuggestedTask(*task) {
return nil, nil, nil, fmt.Errorf("%s失败[%d]%s 不是 suggested 任务,仅 suggested 可参与该工具", toolLabel, task.StateID, task.Name)
return nil, nil, nil, nil, fmt.Errorf("%s失败[%d]%s 不是 suggested 任务,仅 suggested 可参与该工具", toolLabel, task.StateID, task.Name)
}
if err := checkLocked(*task); err != nil {
return nil, nil, nil, fmt.Errorf("%s失败%s", toolLabel, err.Error())
return nil, nil, nil, nil, fmt.Errorf("%s失败%s", toolLabel, err.Error())
}
if len(task.Slots) != 1 {
return nil, nil, nil, fmt.Errorf("%s失败[%d]%s 当前包含 %d 段时段,暂不支持该形态", toolLabel, task.StateID, task.Name, len(task.Slots))
return nil, nil, nil, nil, fmt.Errorf("%s失败[%d]%s 当前包含 %d 段时段,暂不支持该形态", toolLabel, task.StateID, task.Name, len(task.Slots))
}
slot := task.Slots[0]
if err := validateDay(state, slot.Day); err != nil {
return nil, nil, nil, fmt.Errorf("%s失败[%d]%s 的时段非法:%s", toolLabel, task.StateID, task.Name, err.Error())
return nil, nil, nil, nil, fmt.Errorf("%s失败[%d]%s 的时段非法:%s", toolLabel, task.StateID, task.Name, err.Error())
}
if err := validateSlotRange(slot.SlotStart, slot.SlotEnd); err != nil {
return nil, nil, nil, fmt.Errorf("%s失败[%d]%s 的节次非法:%s", toolLabel, task.StateID, task.Name, err.Error())
return nil, nil, nil, nil, fmt.Errorf("%s失败[%d]%s 的节次非法:%s", toolLabel, task.StateID, task.Name, err.Error())
}
week, dayOfWeek, ok := state.DayToWeekDay(slot.Day)
if !ok {
return nil, nil, nil, fmt.Errorf("%s失败[%d]%s 的 day=%d 无法映射到 week/day_of_week", toolLabel, task.StateID, task.Name, slot.Day)
return nil, nil, nil, nil, fmt.Errorf("%s失败[%d]%s 的 day=%d 无法映射到 week/day_of_week", toolLabel, task.StateID, task.Name, slot.Day)
}
contextTag := normalizeMinContextTag(*task)
@@ -340,13 +365,41 @@ func collectCompositePlannerTasks(
})
}
return plannerTasks, beforeByID, excludeIDs, nil
return plannerTasks, beforeByID, excludeIDs, idMapper, nil
}
func buildCurrentSlotsFromPlannerTasks(tasks []refineTaskCandidate) []refineSlotCandidate {
slots := make([]refineSlotCandidate, 0, len(tasks))
// toLogicPlannerTasks 将工具层任务结构映射为 logic 规划器输入。
func toLogicPlannerTasks(tasks []refineTaskCandidate, idMapper *compositeIDMapper) ([]compositelogic.RefineTaskCandidate, error) {
if len(tasks) == 0 {
return nil, fmt.Errorf("任务列表为空")
}
if idMapper == nil {
return nil, fmt.Errorf("ID 映射为空")
}
result := make([]compositelogic.RefineTaskCandidate, 0, len(tasks))
for _, task := range tasks {
slots = append(slots, refineSlotCandidate{
logicID, ok := idMapper.stateToLogic[task.TaskID]
if !ok {
return nil, fmt.Errorf("任务 state_id=%d 缺少 logic 映射", task.TaskID)
}
result = append(result, compositelogic.RefineTaskCandidate{
TaskItemID: logicID,
Week: task.Week,
DayOfWeek: task.DayOfWeek,
SectionFrom: task.SectionFrom,
SectionTo: task.SectionTo,
Name: task.Name,
ContextTag: task.ContextTag,
OriginRank: task.OriginRank,
})
}
return result, nil
}
func buildCurrentSlotsFromPlannerTasks(tasks []compositelogic.RefineTaskCandidate) []compositelogic.RefineSlotCandidate {
slots := make([]compositelogic.RefineSlotCandidate, 0, len(tasks))
for _, task := range tasks {
slots = append(slots, compositelogic.RefineSlotCandidate{
Week: task.Week,
DayOfWeek: task.DayOfWeek,
SectionFrom: task.SectionFrom,
@@ -359,18 +412,26 @@ func buildCurrentSlotsFromPlannerTasks(tasks []refineTaskCandidate) []refineSlot
func buildAfterSnapshotsFromPlannedMoves(
state *ScheduleState,
beforeByID map[int]minContextSnapshot,
plannedMoves []refineMovePlanItem,
plannedMoves []compositelogic.RefineMovePlanItem,
idMapper *compositeIDMapper,
) (map[int]minContextSnapshot, error) {
if len(plannedMoves) == 0 {
return nil, fmt.Errorf("规划结果为空")
}
if idMapper == nil {
return nil, fmt.Errorf("ID 映射为空")
}
moveByID := make(map[int]refineMovePlanItem, len(plannedMoves))
moveByID := make(map[int]compositelogic.RefineMovePlanItem, len(plannedMoves))
for _, move := range plannedMoves {
if _, exists := moveByID[move.TaskID]; exists {
return nil, fmt.Errorf("规划结果包含重复任务 id=%d", move.TaskID)
stateID, ok := idMapper.logicToState[move.TaskItemID]
if !ok {
return nil, fmt.Errorf("规划结果包含未知 logic 任务 id=%d", move.TaskItemID)
}
moveByID[move.TaskID] = move
if _, exists := moveByID[stateID]; exists {
return nil, fmt.Errorf("规划结果包含重复任务 id=%d", stateID)
}
moveByID[stateID] = move
}
afterByID := make(map[int]minContextSnapshot, len(beforeByID))
@@ -401,7 +462,7 @@ func collectSpreadEvenCandidateSlotsBySpan(
state *ScheduleState,
args map[string]any,
spanNeed map[int]int,
) ([]refineSlotCandidate, error) {
) ([]compositelogic.RefineSlotCandidate, error) {
if len(spanNeed) == 0 {
return nil, fmt.Errorf("未识别到任务跨度需求")
}
@@ -412,7 +473,7 @@ func collectSpreadEvenCandidateSlotsBySpan(
}
sort.Ints(spans)
allSlots := make([]refineSlotCandidate, 0, 16)
allSlots := make([]compositelogic.RefineSlotCandidate, 0, 16)
seen := make(map[string]struct{}, 64)
for _, span := range spans {
required := spanNeed[span]
@@ -441,7 +502,7 @@ func collectSpreadEvenCandidateSlotsBySpan(
continue
}
seen[key] = struct{}{}
allSlots = append(allSlots, refineSlotCandidate{
allSlots = append(allSlots, compositelogic.RefineSlotCandidate{
Week: slot.Week,
DayOfWeek: slot.DayOfWeek,
SectionFrom: slot.SlotStart,
@@ -494,7 +555,7 @@ func buildSpreadEvenSlotQueryArgs(args map[string]any, span int, required int) m
func buildSpreadEvenDayLoadBaseline(
state *ScheduleState,
excludeTaskIDs []int,
slots []refineSlotCandidate,
slots []compositelogic.RefineSlotCandidate,
) map[string]int {
if len(slots) == 0 {
return nil
@@ -536,370 +597,10 @@ func buildSpreadEvenDayLoadBaseline(
return load
}
func planEvenSpreadMoves(tasks []refineTaskCandidate, slots []refineSlotCandidate, options refinePlanOptions) ([]refineMovePlanItem, error) {
normalizedTasks, err := normalizePlannerTasks(tasks)
if err != nil {
return nil, err
}
normalizedSlots, err := normalizePlannerSlots(slots)
if err != nil {
return nil, err
}
if len(normalizedSlots) < len(normalizedTasks) {
return nil, fmt.Errorf("可用坑位不足tasks=%d, slots=%d", len(normalizedTasks), len(normalizedSlots))
}
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)
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
score := projectedLoad*10000 + idx
if score < bestScore {
bestScore = score
bestIdx = idx
}
}
if bestIdx < 0 {
return nil, fmt.Errorf("任务 id=%d 无可用同跨度坑位", task.TaskID)
}
chosen := normalizedSlots[bestIdx]
used[bestIdx] = true
selectedSlots = append(selectedSlots, chosen)
dayLoad[composeDayKey(chosen.Week, chosen.DayOfWeek)]++
moves = append(moves, refineMovePlanItem{
TaskID: task.TaskID,
ToWeek: chosen.Week,
ToDay: chosen.DayOfWeek,
ToSectionFrom: chosen.SectionFrom,
ToSectionTo: chosen.SectionTo,
})
}
return moves, nil
}
func planMinContextSwitchMoves(tasks []refineTaskCandidate, slots []refineSlotCandidate, _ refinePlanOptions) ([]refineMovePlanItem, error) {
normalizedTasks, err := normalizePlannerTasks(tasks)
if err != nil {
return nil, err
}
normalizedSlots, err := normalizePlannerSlots(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, len(normalizedTasks))
groupOrder := make([]string, 0, len(normalizedTasks))
for _, task := range normalizedTasks {
key := groupingKeys[task.TaskID]
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))
selectedSlots := make([]refineSlotCandidate, 0, len(orderedTasks))
moves := make([]refineMovePlanItem, 0, len(orderedTasks))
for _, task := range orderedTasks {
span := sectionSpan(task.SectionFrom, task.SectionTo)
chosenIdx := -1
for idx, slot := range normalizedSlots {
if used[idx] {
continue
}
if sectionSpan(slot.SectionFrom, slot.SectionTo) != span {
continue
}
if slotOverlapsAny(slot, selectedSlots) {
continue
}
chosenIdx = idx
break
}
if chosenIdx < 0 {
return nil, fmt.Errorf("任务 id=%d 无可用同跨度坑位", task.TaskID)
}
chosen := normalizedSlots[chosenIdx]
used[chosenIdx] = true
selectedSlots = append(selectedSlots, chosen)
moves = append(moves, refineMovePlanItem{
TaskID: task.TaskID,
ToWeek: chosen.Week,
ToDay: chosen.DayOfWeek,
ToSectionFrom: chosen.SectionFrom,
ToSectionTo: chosen.SectionTo,
})
}
return moves, nil
}
func normalizePlannerTasks(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.TaskID <= 0 {
return nil, fmt.Errorf("存在非法 task_id=%d", task.TaskID)
}
if _, exists := seen[task.TaskID]; exists {
return nil, fmt.Errorf("任务 id=%d 重复", task.TaskID)
}
if !isValidDay(task.DayOfWeek) {
return nil, fmt.Errorf("任务 id=%d day_of_week 非法=%d", task.TaskID, task.DayOfWeek)
}
if !isValidSection(task.SectionFrom, task.SectionTo) {
return nil, fmt.Errorf("任务 id=%d 节次区间非法=%d-%d", task.TaskID, task.SectionFrom, task.SectionTo)
}
seen[task.TaskID] = struct{}{}
normalized = append(normalized, task)
}
sort.SliceStable(normalized, func(i, j int) bool {
return compareTaskOrder(normalized[i], normalized[j]) < 0
})
return normalized, nil
}
func normalizePlannerSlots(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.TaskID - b.TaskID
}
func normalizedOriginRank(task refineTaskCandidate) int {
if task.OriginRank > 0 {
return task.OriginRank
}
return 1_000_000 + task.TaskID
}
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.TaskID] = key
distinctExplicit[key] = struct{}{}
if !isCoarseContextKey(key) {
distinctNonCoarse[key] = struct{}{}
}
}
// 1. 显式标签已经足够区分时,直接沿用;
// 2. 仅在显式标签退化到粗粒度时,才尝试名称兜底。
if len(distinctNonCoarse) >= 2 {
return keys
}
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.TaskID]
}
inferredKeys[task.TaskID] = inferred
distinctInferred[inferred] = struct{}{}
}
if len(distinctInferred) >= 2 {
return inferredKeys
}
return keys
}
func normalizeContextKey(tag string) string {
text := strings.TrimSpace(tag)
if text == "" {
return "General"
}
return text
}
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 ""
}
// 1. 这里使用轻量关键词,不追求全学科覆盖;
// 2. 仅用于“显式标签不足”的兜底场景。
switch {
case strings.Contains(text, "概率"), strings.Contains(text, "随机变量"), strings.Contains(text, "贝叶斯"), strings.Contains(text, "分布"):
return "subject:probability"
case strings.Contains(text, "数制"), strings.Contains(text, "逻辑代数"), strings.Contains(text, "时序电路"), strings.Contains(text, "状态图"):
return "subject:digital_logic"
case strings.Contains(text, "离散"), strings.Contains(text, "图论"), strings.Contains(text, "集合"), strings.Contains(text, "命题逻辑"):
return "subject:discrete_math"
default:
return ""
}
}
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
}
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 uniquePositiveInts(values []int) []int {
seen := make(map[int]struct{}, len(values))
result := make([]int, 0, len(values))

View File

@@ -7,6 +7,7 @@ import (
"strings"
"time"
newagentconv "github.com/LoveLosita/smartflow/backend/newAgent/conv"
newagentgraph "github.com/LoveLosita/smartflow/backend/newAgent/graph"
newagentllm "github.com/LoveLosita/smartflow/backend/newAgent/llm"
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
@@ -485,7 +486,7 @@ func (s *AgentService) makeWriteSchedulePreviewFunc() newagentmodel.WriteSchedul
}
return func(ctx context.Context, state *newagenttools.ScheduleState, userID int, conversationID string, taskClassIDs []int) error {
stateDigest := summarizeScheduleStateForPreviewDebug(state)
preview := conv.ScheduleStateToPreview(state, userID, conversationID, taskClassIDs, "")
preview := newagentconv.ScheduleStateToPreview(state, userID, conversationID, taskClassIDs, "")
if preview == nil {
log.Printf("[WARN] schedule preview skipped chat=%s user=%d state=%s", conversationID, userID, stateDigest)
return nil