Version: 0.9.12.dev.260410
后端: 1. chat 路由新增“二次粗排硬闸门”,避免粗排完成后的微调请求误触发再次 rough_build - 更新 node/chat.go:当上下文已存在 rough_build_done 且用户未明确要求“重新粗排/从头重排”时,强制关闭 needs_rough_build / needs_refine_after_rough_build;补充路由调试日志维度(needs_rough_build、allow_reorder、has_rough_build_done 等) - 更新 prompt/chat.go:补齐二次粗排强约束,明确“移动/微调/优化/均匀化/调顺序”默认走 refine,不再次触发 rough build 2. execute 历史分层与工具调用写回链路增强 - 更新 node/execute.go:next_plan 推进后写入 execute_step_advanced marker,供 prompt 按步骤边界归档 loop;新增统一 appendToolCallResultHistory,标准化 assistant tool_call + tool observation 配对写回 - 更新 node/execute.go:confirm accept 路径补齐 min_context_switch 顺序护栏,避免通过确认链路绕过“未授权打乱顺序”限制 - 更新 prompt/execute_context.go:ReAct 边界识别从 loop_closed 扩展到 loop_closed/step_advanced;执行态文案收敛为“existing 仅作事实参考不作为可移动目标”,并新增参数纪律提示 - 更新 service/agentsvc/agent_newagent.go:冷恢复重置时仅在 completed 场景补写 execute_loop_closed marker,保证下一轮上下文归档一致 3. 工具参数严格校验落地(禁止自造字段) - 新建 tools/arg_guard.go:新增 validateToolArgsStrict 白名单校验,未知字段直接报错(含 day_from/day_to -> day_start/day_end 提示) - 更新 tools/read_filter_tools.go:query_available_slots / query_target_tasks 接入参数白名单校验 - 更新 tools/compound_tools.go:spread_even 接入参数白名单校验 - 更新 prompt/execute.go:系统提示补齐“参数必须严格使用 schema 字段”强约束与非法别名示例 4. execute 范围护栏辅助能力预埋 - 更新 node/execute.go:新增步骤范围解析与日历参数解析辅助(周/天/周几提取、候选 day 估算、batch_move new_day 提取等),为后续步骤级范围拦截提供基础能力 5. 记忆模块方案文档升级(吸收 Mem0 机制) - 更新 memory/记忆模块实施计划.md:补充 Mem0 借鉴与取舍,新增 ADD/UPDATE/DELETE/NONE 决策状态机、UUID 映射防幻觉、JSON 容错链、threshold->reranker->fallback、三维隔离过滤与对应指标/测试项 6. 同步更新调试日志文件 - 更新 newAgent/Log.txt 前端:无 仓库:无
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
2. 兼容当前单体工程,不引入高风险拆分,不破坏现有聊天主链路。
|
2. 兼容当前单体工程,不引入高风险拆分,不破坏现有聊天主链路。
|
||||||
3. 复用现有 Outbox 异步基础设施,避免重复造轮子。
|
3. 复用现有 Outbox 异步基础设施,避免重复造轮子。
|
||||||
4. 形成可直接用于面试讲述的架构故事线、指标体系与演示脚本。
|
4. 形成可直接用于面试讲述的架构故事线、指标体系与演示脚本。
|
||||||
|
5. 在不增加过度复杂度的前提下,吸收 Mem0 中已被验证的关键机制(抽取、决策、检索、降级、防幻觉)。
|
||||||
|
|
||||||
## 2. 背景与约束
|
## 2. 背景与约束
|
||||||
|
|
||||||
@@ -40,6 +41,21 @@
|
|||||||
1. 写入链路:候选抽取 -> 去重/冲突 -> 打分 -> 分流落库(MySQL/Milvus)。
|
1. 写入链路:候选抽取 -> 去重/冲突 -> 打分 -> 分流落库(MySQL/Milvus)。
|
||||||
2. 读取链路:硬约束优先 -> 语义召回补充 -> 重排 -> 门控 -> 注入上下文。
|
2. 读取链路:硬约束优先 -> 语义召回补充 -> 重排 -> 门控 -> 注入上下文。
|
||||||
|
|
||||||
|
### 3.4 借鉴 Mem0 的关键机制(已裁剪版)
|
||||||
|
|
||||||
|
1. 双阶段去重决策:先向量召回候选旧记忆,再由 LLM 决策 `ADD/UPDATE/DELETE/NONE`,而不是只靠相似度阈值硬判。
|
||||||
|
2. UUID 映射防幻觉:把真实 `memory_id` 映射成临时整数给 LLM,回收结果时再反查,防止模型编造不存在 ID。
|
||||||
|
3. 结构化输出刚性约束:抽取与决策都用 JSON 结构,失败时走 `extract_json -> normalize_facts` 容错链,不让解析失败直接污染主流程。
|
||||||
|
4. 动作分型嵌入:嵌入接口显式传入 `memory_action`(`add/search/update`),为后续差异化 embedding 策略预留接口。
|
||||||
|
5. 检索后处理标准化:`threshold 过滤 -> 可选 reranker -> 统一降级`,当重排器异常时保留向量原始排序并打告警日志。
|
||||||
|
6. 多维隔离语义:统一采用 `user_id + agent_id + run_id` 三维过滤;在本项目映射为 `user_id + assistant_id + conversation_id`。
|
||||||
|
|
||||||
|
### 3.5 本项目明确不做(本轮)
|
||||||
|
|
||||||
|
1. 不做图记忆(Graph Memory)落地实现,仅预留扩展点,避免 3 天范围失控。
|
||||||
|
2. 不做多 Provider 工厂体系,只保留单 Provider 可替换接口,后续再扩展。
|
||||||
|
3. 不做独立 server 化记忆服务,先在单体内完成闭环与指标验证。
|
||||||
|
|
||||||
## 4. 3 天执行计划(可直接照着做)
|
## 4. 3 天执行计划(可直接照着做)
|
||||||
|
|
||||||
## Day 1:把“可写入”打通(可靠入队 + 可追踪)
|
## Day 1:把“可写入”打通(可靠入队 + 可追踪)
|
||||||
@@ -64,13 +80,21 @@
|
|||||||
- `memory_jobs`
|
- `memory_jobs`
|
||||||
- `memory_audit_logs`
|
- `memory_audit_logs`
|
||||||
- `memory_user_settings`
|
- `memory_user_settings`
|
||||||
3. 新增 Outbox 事件:
|
3. 新增配置对象(`memory config`):
|
||||||
|
- 抽取 prompt、更新决策 prompt、阈值、是否启用 reranker、LLM 温度参数。
|
||||||
|
- 默认采用低随机参数(`temperature/top_p` 低值)提高可复现性。
|
||||||
|
4. 新增 Outbox 事件:
|
||||||
- `memory.extract.requested`(v1)
|
- `memory.extract.requested`(v1)
|
||||||
4. 在聊天后置持久化环节发布事件:
|
5. 在聊天后置持久化环节发布事件:
|
||||||
- 仅传轻量字段,避免超大 payload。
|
- 仅传轻量字段,避免超大 payload。
|
||||||
5. 新增消费处理器:
|
6. 新增消费处理器:
|
||||||
- 只做任务入库,不做重型 LLM 调用。
|
- 只做任务入库,不做重型 LLM 调用。
|
||||||
6. 启动期接线:
|
7. 新增解析与标准化工具:
|
||||||
|
- `extract_json()`:从模型输出中抽取 JSON(兼容代码块包裹)。
|
||||||
|
- `normalize_facts()`:去重、去空、长度校验、非法项过滤。
|
||||||
|
8. 新增决策状态机定义:
|
||||||
|
- `ADD/UPDATE/DELETE/NONE` 的合法状态与动作映射。
|
||||||
|
9. 启动期接线:
|
||||||
- 在 `backend/cmd/start.go` 注册记忆事件处理器。
|
- 在 `backend/cmd/start.go` 注册记忆事件处理器。
|
||||||
|
|
||||||
### Day 1 验收标准
|
### Day 1 验收标准
|
||||||
@@ -101,12 +125,16 @@
|
|||||||
- 置信度阈值。
|
- 置信度阈值。
|
||||||
- 时间衰减权重。
|
- 时间衰减权重。
|
||||||
- 敏感级别检查。
|
- 敏感级别检查。
|
||||||
4. 新增最小管理接口:
|
4. 增加“阈值 + 可选重排 + 降级”链路:
|
||||||
|
- 阈值过滤作为第一道过滤。
|
||||||
|
- `reranker` 失败时自动降级为原排序并记录原因码。
|
||||||
|
5. 新增最小管理接口:
|
||||||
- `GET /api/v1/memory/items`
|
- `GET /api/v1/memory/items`
|
||||||
- `DELETE /api/v1/memory/items/:id`
|
- `DELETE /api/v1/memory/items/:id`
|
||||||
- `POST /api/v1/memory/settings`(开关)
|
- `POST /api/v1/memory/settings`(开关)
|
||||||
5. 完成首版日志埋点:
|
6. 完成首版日志埋点:
|
||||||
- 检索命中数、注入条数、门控丢弃原因。
|
- 检索命中数、注入条数、门控丢弃原因。
|
||||||
|
- 决策分布(ADD/UPDATE/DELETE/NONE 占比)。
|
||||||
|
|
||||||
### Day 2 验收标准
|
### Day 2 验收标准
|
||||||
|
|
||||||
@@ -128,6 +156,7 @@
|
|||||||
- `VectorStore.Upsert()`
|
- `VectorStore.Upsert()`
|
||||||
- `VectorStore.Search()`
|
- `VectorStore.Search()`
|
||||||
- `VectorStore.Delete()`
|
- `VectorStore.Delete()`
|
||||||
|
- `VectorStore.Get()`(为 UPDATE/DELETE 决策回查旧值)
|
||||||
2. 对接 Milvus(可选):
|
2. 对接 Milvus(可选):
|
||||||
- collection 初始化。
|
- collection 初始化。
|
||||||
- 向量 + 元数据过滤检索。
|
- 向量 + 元数据过滤检索。
|
||||||
@@ -140,6 +169,9 @@
|
|||||||
- 5 分钟架构说明。
|
- 5 分钟架构说明。
|
||||||
- 3 个典型失败案例及兜底策略。
|
- 3 个典型失败案例及兜底策略。
|
||||||
- 未来迭代路线。
|
- 未来迭代路线。
|
||||||
|
5. 输出“借鉴 Mem0 但本地化裁剪”的对比说明:
|
||||||
|
- 借鉴了什么。
|
||||||
|
- 为什么暂时不做图记忆与多 Provider 工厂。
|
||||||
|
|
||||||
### Day 3 验收标准
|
### Day 3 验收标准
|
||||||
|
|
||||||
@@ -158,31 +190,36 @@
|
|||||||
1. `id` bigint PK
|
1. `id` bigint PK
|
||||||
2. `user_id` bigint(必填)
|
2. `user_id` bigint(必填)
|
||||||
3. `conversation_id` varchar(64)(可空,表示全局用户记忆)
|
3. `conversation_id` varchar(64)(可空,表示全局用户记忆)
|
||||||
4. `memory_type` varchar(32)
|
4. `assistant_id` varchar(64)(可空,区分不同助手人格/技能域)
|
||||||
|
5. `run_id` varchar(64)(可空,会话级隔离)
|
||||||
|
6. `memory_type` varchar(32)
|
||||||
- `preference`(偏好)
|
- `preference`(偏好)
|
||||||
- `constraint`(硬约束)
|
- `constraint`(硬约束)
|
||||||
- `fact`(事实)
|
- `fact`(事实)
|
||||||
- `todo_hint`(近期提醒线索)
|
- `todo_hint`(近期提醒线索)
|
||||||
5. `title` varchar(128)
|
7. `title` varchar(128)
|
||||||
6. `content` text
|
8. `content` text
|
||||||
7. `normalized_content` text(去噪后)
|
9. `normalized_content` text(去噪后)
|
||||||
8. `confidence` decimal(5,4)(0~1)
|
10. `content_hash` varchar(64)(幂等去重)
|
||||||
9. `importance` decimal(5,4)(0~1)
|
11. `confidence` decimal(5,4)(0~1)
|
||||||
10. `sensitivity_level` tinyint
|
12. `importance` decimal(5,4)(0~1)
|
||||||
|
13. `sensitivity_level` tinyint
|
||||||
- 0 普通
|
- 0 普通
|
||||||
- 1 中敏
|
- 1 中敏
|
||||||
- 2 高敏
|
- 2 高敏
|
||||||
11. `source_message_id` bigint
|
14. `source_message_id` bigint
|
||||||
12. `source_event_id` varchar(64)
|
15. `source_event_id` varchar(64)
|
||||||
13. `is_explicit` tinyint(1)(是否用户明确要求记住)
|
16. `is_explicit` tinyint(1)(是否用户明确要求记住)
|
||||||
14. `status` varchar(16)
|
17. `status` varchar(16)
|
||||||
- `active`
|
- `active`
|
||||||
- `archived`
|
- `archived`
|
||||||
- `deleted`
|
- `deleted`
|
||||||
15. `ttl_at` datetime(到期时间)
|
18. `ttl_at` datetime(到期时间)
|
||||||
16. `last_access_at` datetime
|
19. `last_access_at` datetime
|
||||||
17. `created_at` datetime
|
20. `created_at` datetime
|
||||||
18. `updated_at` datetime
|
21. `updated_at` datetime
|
||||||
|
22. `vector_status` varchar(16)(`pending/synced/failed`)
|
||||||
|
23. `vector_id` varchar(128)(向量库主键映射)
|
||||||
|
|
||||||
索引建议:
|
索引建议:
|
||||||
|
|
||||||
@@ -190,6 +227,8 @@
|
|||||||
2. `(user_id, conversation_id, status, updated_at desc)`
|
2. `(user_id, conversation_id, status, updated_at desc)`
|
||||||
3. `(source_message_id)`(排查链路)
|
3. `(source_message_id)`(排查链路)
|
||||||
4. `(ttl_at)`(过期清理)
|
4. `(ttl_at)`(过期清理)
|
||||||
|
5. `(user_id, assistant_id, run_id, status, updated_at desc)`
|
||||||
|
6. `(user_id, memory_type, content_hash)`(幂等去重)
|
||||||
|
|
||||||
## 5.2 `memory_jobs`(异步任务队列表)
|
## 5.2 `memory_jobs`(异步任务队列表)
|
||||||
|
|
||||||
@@ -206,25 +245,27 @@
|
|||||||
- `extract`
|
- `extract`
|
||||||
- `embed`
|
- `embed`
|
||||||
- `reconcile`
|
- `reconcile`
|
||||||
7. `payload_json` longtext
|
7. `idempotency_key` varchar(128)
|
||||||
8. `status` varchar(16)
|
8. `payload_json` longtext
|
||||||
|
9. `status` varchar(16)
|
||||||
- `pending`
|
- `pending`
|
||||||
- `processing`
|
- `processing`
|
||||||
- `success`
|
- `success`
|
||||||
- `failed`
|
- `failed`
|
||||||
- `dead`
|
- `dead`
|
||||||
9. `retry_count` int
|
10. `retry_count` int
|
||||||
10. `max_retry` int
|
11. `max_retry` int
|
||||||
11. `next_retry_at` datetime
|
12. `next_retry_at` datetime
|
||||||
12. `last_error` varchar(2000)
|
13. `last_error` varchar(2000)
|
||||||
13. `created_at` datetime
|
14. `created_at` datetime
|
||||||
14. `updated_at` datetime
|
15. `updated_at` datetime
|
||||||
|
|
||||||
索引建议:
|
索引建议:
|
||||||
|
|
||||||
1. `(status, next_retry_at, id)`
|
1. `(status, next_retry_at, id)`
|
||||||
2. `(user_id, created_at desc)`
|
2. `(user_id, created_at desc)`
|
||||||
3. `(source_event_id)`(幂等与追踪)
|
3. `(source_event_id)`(幂等与追踪)
|
||||||
|
4. `(idempotency_key)`(消费防重)
|
||||||
|
|
||||||
## 5.3 `memory_audit_logs`(审计日志)
|
## 5.3 `memory_audit_logs`(审计日志)
|
||||||
|
|
||||||
@@ -274,17 +315,21 @@
|
|||||||
|
|
||||||
1. `user_id`
|
1. `user_id`
|
||||||
2. `conversation_id`
|
2. `conversation_id`
|
||||||
3. `source_message_id`
|
3. `assistant_id`
|
||||||
4. `source_role`
|
4. `run_id`
|
||||||
5. `source_text`
|
5. `source_message_id`
|
||||||
6. `occurred_at`
|
6. `source_role`
|
||||||
7. `trace_id`
|
7. `source_text`
|
||||||
|
8. `occurred_at`
|
||||||
|
9. `trace_id`
|
||||||
|
10. `idempotency_key`
|
||||||
|
|
||||||
设计约束:
|
设计约束:
|
||||||
|
|
||||||
1. Payload 只放执行需要的最小字段。
|
1. Payload 只放执行需要的最小字段。
|
||||||
2. 大文本允许截断并保留摘要,防止消息膨胀。
|
2. 大文本允许截断并保留摘要,防止消息膨胀。
|
||||||
3. 必须包含幂等标识(如 `source_message_id + user_id`)。
|
3. 必须包含幂等标识(如 `source_message_id + user_id`)。
|
||||||
|
4. 过滤维度必须完整(`user_id + assistant_id + run_id`),避免跨会话串记忆。
|
||||||
|
|
||||||
## 7. 写入流程详细设计
|
## 7. 写入流程详细设计
|
||||||
|
|
||||||
@@ -295,12 +340,16 @@
|
|||||||
3. Outbox 消费处理器验证 payload。
|
3. Outbox 消费处理器验证 payload。
|
||||||
4. 处理器创建或幂等更新 `memory_jobs`(仅任务入库)。
|
4. 处理器创建或幂等更新 `memory_jobs`(仅任务入库)。
|
||||||
5. `memory/worker` 扫描 `pending` 任务并抢占为 `processing`。
|
5. `memory/worker` 扫描 `pending` 任务并抢占为 `processing`。
|
||||||
6. Worker 调用 LLM 执行“候选记忆抽取”。
|
6. Worker 调用 LLM 执行“候选事实抽取”(JSON 输出)。
|
||||||
7. 执行标准化(时间归一化、实体归一化、噪声去除)。
|
7. 执行 `extract_json -> normalize_facts` 容错标准化链路。
|
||||||
8. 执行冲突消解(同类偏好最新优先、互斥约束降权)。
|
8. 对每条候选事实做向量检索,召回 Top-K 旧记忆候选。
|
||||||
9. 计算分值(置信度、重要度、时效度)。
|
9. 对召回结果执行“临时整数 ID 映射”,再交给 LLM 决策 `ADD/UPDATE/DELETE/NONE`。
|
||||||
10. 写入 `memory_items` 与审计日志。
|
10. 根据决策执行写入动作:
|
||||||
11. 触发向量化(同步或异步二选一)。
|
- `ADD`:新增 `memory_items` + 审计日志。
|
||||||
|
- `UPDATE`:更新记录并保留历史旧值。
|
||||||
|
- `DELETE`:软删除并记录删除原因。
|
||||||
|
- `NONE`:不写入,仅记调试日志。
|
||||||
|
11. 按决策动作触发向量同步(支持 `vector_pending`)。
|
||||||
12. 成功后任务标记 `success`,失败按重试策略推进。
|
12. 成功后任务标记 `success`,失败按重试策略推进。
|
||||||
|
|
||||||
## 7.2 失败处理策略
|
## 7.2 失败处理策略
|
||||||
@@ -317,6 +366,7 @@
|
|||||||
1. 幂等键:`user_id + source_message_id + memory_type + normalized_content_hash`
|
1. 幂等键:`user_id + source_message_id + memory_type + normalized_content_hash`
|
||||||
2. 同幂等键重复写入:更新 `updated_at`、提升访问热度,不新增重复条目。
|
2. 同幂等键重复写入:更新 `updated_at`、提升访问热度,不新增重复条目。
|
||||||
3. 由 Outbox 重试导致的重复消费必须无副作用。
|
3. 由 Outbox 重试导致的重复消费必须无副作用。
|
||||||
|
4. 对 UPDATE/DELETE 必须先校验目标 `memory_id` 是否存在且属于当前过滤域。
|
||||||
|
|
||||||
## 8. 读取流程详细设计
|
## 8. 读取流程详细设计
|
||||||
|
|
||||||
@@ -334,8 +384,9 @@
|
|||||||
- 低相关丢弃
|
- 低相关丢弃
|
||||||
- 高敏过滤
|
- 高敏过滤
|
||||||
- 过期过滤
|
- 过期过滤
|
||||||
6. 按 token budget 选择最终注入条目。
|
6. 执行阈值过滤后可选 reranker;若 reranker 异常则自动降级使用原排序。
|
||||||
7. 组装统一注入上下文,传给主模型生成回复。
|
7. 按 token budget 选择最终注入条目。
|
||||||
|
8. 组装统一注入上下文,传给主模型生成回复。
|
||||||
|
|
||||||
## 8.2 重排评分(建议公式)
|
## 8.2 重排评分(建议公式)
|
||||||
|
|
||||||
@@ -383,6 +434,9 @@
|
|||||||
5. `memory_wrong_mention_rate`
|
5. `memory_wrong_mention_rate`
|
||||||
6. `memory_user_correction_rate`
|
6. `memory_user_correction_rate`
|
||||||
7. `chat_p95_latency_delta_with_memory`
|
7. `chat_p95_latency_delta_with_memory`
|
||||||
|
8. `memory_json_parse_fail_rate`
|
||||||
|
9. `memory_decision_distribution`(ADD/UPDATE/DELETE/NONE)
|
||||||
|
10. `reranker_fallback_rate`
|
||||||
|
|
||||||
## 10.2 日志与追踪
|
## 10.2 日志与追踪
|
||||||
|
|
||||||
@@ -410,6 +464,10 @@
|
|||||||
3. 重排评分函数。
|
3. 重排评分函数。
|
||||||
4. 门控函数。
|
4. 门控函数。
|
||||||
5. 幂等去重函数。
|
5. 幂等去重函数。
|
||||||
|
6. `extract_json` 容错解析函数。
|
||||||
|
7. `normalize_facts` 标准化函数。
|
||||||
|
8. UUID 映射与反查函数。
|
||||||
|
9. `ADD/UPDATE/DELETE/NONE` 决策结果校验函数。
|
||||||
|
|
||||||
## 12.2 集成测试范围(实现阶段)
|
## 12.2 集成测试范围(实现阶段)
|
||||||
|
|
||||||
@@ -450,7 +508,9 @@
|
|||||||
1. “我们做的是同步快路径 + 异步慢路径。同步保证下轮可用,异步负责治理和质量。”
|
1. “我们做的是同步快路径 + 异步慢路径。同步保证下轮可用,异步负责治理和质量。”
|
||||||
2. “结构化事实放 MySQL 保证可控可审计,语义联想放 Milvus 提高召回覆盖。”
|
2. “结构化事实放 MySQL 保证可控可审计,语义联想放 Milvus 提高召回覆盖。”
|
||||||
3. “Outbox 保证事件可靠入队,Worker 解耦重计算,避免阻塞主链路。”
|
3. “Outbox 保证事件可靠入队,Worker 解耦重计算,避免阻塞主链路。”
|
||||||
4. “我们用命中率、误提率、纠正率三项核心指标验证记忆是否真的有价值。”
|
4. “借鉴 Mem0 的双阶段策略:先向量召回旧记忆,再让 LLM 决策 ADD/UPDATE/DELETE/NONE,兼顾召回率与准确率。”
|
||||||
|
5. “我们用 UUID 映射防止模型伪造 ID,并且用 JSON 容错链保证抽取稳定性。”
|
||||||
|
6. “我们用命中率、误提率、纠正率和 reranker 降级率验证记忆是否真的有价值。”
|
||||||
|
|
||||||
## 15. DoD(完成定义)
|
## 15. DoD(完成定义)
|
||||||
|
|
||||||
@@ -471,7 +531,28 @@
|
|||||||
2. 再做 Day 2 的读取注入,优先 MySQL 结构化记忆。
|
2. 再做 Day 2 的读取注入,优先 MySQL 结构化记忆。
|
||||||
3. 最后补 Day 3 的 Milvus 与指标,确保面试讲述闭环。
|
3. 最后补 Day 3 的 Milvus 与指标,确保面试讲述闭环。
|
||||||
|
|
||||||
|
## 17. Mem0 借鉴清单与取舍结论(本轮新增)
|
||||||
|
|
||||||
|
### 17.1 直接借鉴
|
||||||
|
|
||||||
|
1. `ADD/UPDATE/DELETE/NONE` 统一决策状态机。
|
||||||
|
2. `threshold -> reranker(可选) -> fallback` 的检索后处理套路。
|
||||||
|
3. 三维过滤隔离(`user_id/agent_id/run_id`)的语义边界设计。
|
||||||
|
4. 历史追踪思路(本项目落在 `memory_audit_logs`)。
|
||||||
|
5. 低随机参数 + JSON 输出约束,提升可复现性。
|
||||||
|
|
||||||
|
### 17.2 延后借鉴
|
||||||
|
|
||||||
|
1. 图记忆(关系三元组与软删除)延后到 V2/V3。
|
||||||
|
2. 多 Provider 工厂体系延后到“需要跨云/跨模型”时再上。
|
||||||
|
3. 托管 API 平台化能力延后到单体稳定后再拆。
|
||||||
|
|
||||||
|
### 17.3 不照搬的原因
|
||||||
|
|
||||||
|
1. 当前目标是 3 天可演示 MVP,优先“稳定可讲”而非“能力最全”。
|
||||||
|
2. 项目已有 Outbox 可靠链路,先最大化复用,避免架构重复。
|
||||||
|
3. 日程助手是强约束场景,结构化事实主库优先级高于图谱表达能力。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
本文件定位为“落地执行蓝图”。后续每完成一块能力,建议在本文件追加“已落地清单 + 待办差距”,持续收敛为真实实施记录。
|
本文件定位为“落地执行蓝图”。后续每完成一块能力,建议在本文件追加“已落地清单 + 待办差距”,持续收敛为真实实施记录。
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -126,8 +126,26 @@ func RunChatNode(ctx context.Context, input ChatNodeInput) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[DEBUG] chat routing chat=%s route=%s reason=%s",
|
// 1. 二次粗排硬闸门:若上下文已存在 rough_build_done 且用户未明确要求“重新粗排”,
|
||||||
flowState.ConversationID, decision.Route, decision.Reason)
|
// 则强制关闭 needs_rough_build,避免“微调请求被误判成再次粗排”。
|
||||||
|
// 2. 该闸门只收紧粗排开关,不改路由 route,确保 execute 微调链路仍可继续。
|
||||||
|
// 3. 一旦用户明确表达“从头重排/重新粗排”,仍允许 needs_rough_build=true 生效。
|
||||||
|
if shouldDisableRoughBuildForRefine(conversationContext, input.UserInput, decision) {
|
||||||
|
decision.NeedsRoughBuild = false
|
||||||
|
decision.NeedsRefineAfterRoughBuild = false
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf(
|
||||||
|
"[DEBUG] chat routing chat=%s route=%s needs_rough_build=%v needs_refine_after_rough_build=%v allow_reorder=%v has_rough_build_done=%v task_class_count=%d reason=%s",
|
||||||
|
flowState.ConversationID,
|
||||||
|
decision.Route,
|
||||||
|
decision.NeedsRoughBuild,
|
||||||
|
decision.NeedsRefineAfterRoughBuild,
|
||||||
|
decision.AllowReorder,
|
||||||
|
hasRoughBuildDoneMarker(conversationContext),
|
||||||
|
len(flowState.TaskClassIDs),
|
||||||
|
decision.Reason,
|
||||||
|
)
|
||||||
flowState.AllowReorder = resolveAllowReorder(input.UserInput, decision.AllowReorder)
|
flowState.AllowReorder = resolveAllowReorder(input.UserInput, decision.AllowReorder)
|
||||||
|
|
||||||
// 3. 按路由决策推进。
|
// 3. 按路由决策推进。
|
||||||
@@ -314,6 +332,62 @@ func containsAnyPhrase(text string, phrases []string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shouldDisableRoughBuildForRefine 判断是否应在 chat 路由阶段关闭“再次粗排”。
|
||||||
|
//
|
||||||
|
// 判定规则:
|
||||||
|
// 1. 当前决策未请求粗排时,直接不干预;
|
||||||
|
// 2. 上下文不存在 rough_build_done 时,不干预(首次粗排仍可走);
|
||||||
|
// 3. 若用户未明确要求“重新粗排/从头重排”,则关闭粗排开关,避免误触发。
|
||||||
|
func shouldDisableRoughBuildForRefine(
|
||||||
|
conversationContext *newagentmodel.ConversationContext,
|
||||||
|
userInput string,
|
||||||
|
decision *newagentmodel.ChatRoutingDecision,
|
||||||
|
) bool {
|
||||||
|
if decision == nil || !decision.NeedsRoughBuild {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !hasRoughBuildDoneMarker(conversationContext) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !isExplicitRoughBuildRequest(userInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasRoughBuildDoneMarker(conversationContext *newagentmodel.ConversationContext) bool {
|
||||||
|
if conversationContext == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, block := range conversationContext.PinnedBlocksSnapshot() {
|
||||||
|
if strings.TrimSpace(block.Key) == "rough_build_done" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isExplicitRoughBuildRequest 识别用户是否明确要求“重新粗排/从头重排”。
|
||||||
|
func isExplicitRoughBuildRequest(userInput string) bool {
|
||||||
|
text := strings.ToLower(strings.TrimSpace(userInput))
|
||||||
|
if text == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
keywords := []string{
|
||||||
|
"重新粗排",
|
||||||
|
"重做粗排",
|
||||||
|
"从头排",
|
||||||
|
"从头重排",
|
||||||
|
"重新排一遍",
|
||||||
|
"重新排课",
|
||||||
|
"重排全部",
|
||||||
|
"全部重排",
|
||||||
|
"重置排程",
|
||||||
|
"重置后重排",
|
||||||
|
"重新生成初稿",
|
||||||
|
"rebuild",
|
||||||
|
"from scratch",
|
||||||
|
}
|
||||||
|
return containsAnyPhrase(text, keywords)
|
||||||
|
}
|
||||||
|
|
||||||
// handleDeepAnswer 处理复杂问答:推送过渡语 → 原地开 thinking 再调一次 LLM → 输出深度回答。
|
// handleDeepAnswer 处理复杂问答:推送过渡语 → 原地开 thinking 再调一次 LLM → 输出深度回答。
|
||||||
func handleDeepAnswer(
|
func handleDeepAnswer(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -19,11 +20,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
executeStageName = "execute"
|
executeStageName = "execute"
|
||||||
executeStatusBlockID = "execute.status"
|
executeStatusBlockID = "execute.status"
|
||||||
executeSpeakBlockID = "execute.speak"
|
executeSpeakBlockID = "execute.speak"
|
||||||
executePinnedKey = "execution_context"
|
executePinnedKey = "execution_context"
|
||||||
toolMinContextSwitch = "min_context_switch"
|
toolMinContextSwitch = "min_context_switch"
|
||||||
|
executeHistoryKindKey = "newagent_history_kind"
|
||||||
|
executeHistoryKindStepAdvanced = "execute_step_advanced"
|
||||||
|
|
||||||
// maxConsecutiveCorrections 是 Execute 节点连续修正次数上限。
|
// maxConsecutiveCorrections 是 Execute 节点连续修正次数上限。
|
||||||
// 超过此阈值后终止执行,防止 LLM 陷入无限修正循环。
|
// 超过此阈值后终止执行,防止 LLM 陷入无限修正循环。
|
||||||
@@ -404,6 +407,9 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
|||||||
// 所有步骤已完成,进入交付阶段。
|
// 所有步骤已完成,进入交付阶段。
|
||||||
flowState.Done()
|
flowState.Done()
|
||||||
}
|
}
|
||||||
|
// 1. 写入“步骤推进完成”边界标记,把上一步骤 loop 从 msg2 挪入 msg1。
|
||||||
|
// 2. 标记只作为 prompt 分层锚点,不参与业务语义判断。
|
||||||
|
appendExecuteStepAdvancedMarker(conversationContext)
|
||||||
// 1. next_plan 推进后立刻刷新 current_step / execution_context。
|
// 1. next_plan 推进后立刻刷新 current_step / execution_context。
|
||||||
// 2. 若计划已结束,这里会移除 current_step,避免下轮读取到旧步骤。
|
// 2. 若计划已结束,这里会移除 current_step,避免下轮读取到旧步骤。
|
||||||
syncExecutePinnedContext(conversationContext, flowState)
|
syncExecutePinnedContext(conversationContext, flowState)
|
||||||
@@ -515,6 +521,36 @@ func syncExecutePinnedContext(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// appendExecuteStepAdvancedMarker 在 history 中写入“步骤已推进”标记。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 仅写轻量 marker,供 prompt 侧把“上一步骤 loop”归档进 msg1;
|
||||||
|
// 2. 若末尾已是同类 marker,则幂等跳过;
|
||||||
|
// 3. 不负责裁剪历史、不负责摘要压缩。
|
||||||
|
func appendExecuteStepAdvancedMarker(conversationContext *newagentmodel.ConversationContext) {
|
||||||
|
if conversationContext == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
history := conversationContext.HistorySnapshot()
|
||||||
|
if len(history) > 0 {
|
||||||
|
last := history[len(history)-1]
|
||||||
|
if last != nil && last.Extra != nil {
|
||||||
|
if kind, ok := last.Extra[executeHistoryKindKey].(string); ok && strings.TrimSpace(kind) == executeHistoryKindStepAdvanced {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conversationContext.AppendHistory(&schema.Message{
|
||||||
|
Role: schema.Assistant,
|
||||||
|
Content: "",
|
||||||
|
Extra: map[string]any{
|
||||||
|
executeHistoryKindKey: executeHistoryKindStepAdvanced,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// buildExecuteContextPinnedMarkdown 构造 execute 节点给模型的执行锚点文本。
|
// buildExecuteContextPinnedMarkdown 构造 execute 节点给模型的执行锚点文本。
|
||||||
func buildExecuteContextPinnedMarkdown(flowState *newagentmodel.CommonState) string {
|
func buildExecuteContextPinnedMarkdown(flowState *newagentmodel.CommonState) string {
|
||||||
if flowState == nil {
|
if flowState == nil {
|
||||||
@@ -690,6 +726,595 @@ func handleExecuteActionAbort(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// executeStepScope 描述当前计划步骤提取出的“硬范围约束”。
|
||||||
|
//
|
||||||
|
// 约束语义:
|
||||||
|
// 1. WeekFrom/WeekTo:限制到指定周范围;
|
||||||
|
// 2. DayStart/DayEnd:限制到指定 day_index 范围;
|
||||||
|
// 3. DayOfWeekSet:限制到指定周几集合(1=周一 ... 7=周日)。
|
||||||
|
type executeStepScope struct {
|
||||||
|
HasWeek bool
|
||||||
|
WeekFrom int
|
||||||
|
WeekTo int
|
||||||
|
|
||||||
|
HasDay bool
|
||||||
|
DayStart int
|
||||||
|
DayEnd int
|
||||||
|
|
||||||
|
DayOfWeekSet map[int]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
executeScopeWeekRangeRe = regexp.MustCompile(`第\s*(\d+)\s*(?:-|到|至|~)\s*(\d+)\s*周`)
|
||||||
|
executeScopeWeekSingleRe = regexp.MustCompile(`第\s*(\d+)\s*周`)
|
||||||
|
executeScopeDayRangeReA = regexp.MustCompile(`第\s*(\d+)\s*(?:-|到|至|~)\s*(\d+)\s*天`)
|
||||||
|
executeScopeDayRangeReB = regexp.MustCompile(`第\s*(\d+)\s*天\s*(?:-|到|至|~)\s*第?\s*(\d+)\s*天`)
|
||||||
|
executeScopeDaySingleRe = regexp.MustCompile(`第\s*(\d+)\s*天`)
|
||||||
|
executeScopeWeekdayRangeRe = regexp.MustCompile(`周\s*([一二三四五六日天])\s*(?:-|到|至|~)\s*周?\s*([一二三四五六日天])`)
|
||||||
|
executeScopeWeekdayRe = regexp.MustCompile(`周\s*([一二三四五六日天])`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// deriveExecuteStepScope 从当前步骤文本提取范围锚点。
|
||||||
|
//
|
||||||
|
// 提取优先级:
|
||||||
|
// 1. 优先识别“第X周 / 第X-Y周”;
|
||||||
|
// 2. 其次识别“周一到周五 / 工作日 / 周末”等周几约束;
|
||||||
|
// 3. 补充识别“第A-B天 / 第A天到第B天”。
|
||||||
|
func deriveExecuteStepScope(flowState *newagentmodel.CommonState) (*executeStepScope, bool) {
|
||||||
|
if flowState == nil || !flowState.HasPlan() {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
step, ok := flowState.CurrentPlanStep()
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
text := strings.TrimSpace(step.Content + "\n" + step.DoneWhen)
|
||||||
|
if text == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := &executeStepScope{
|
||||||
|
DayOfWeekSet: make(map[int]struct{}, 7),
|
||||||
|
}
|
||||||
|
hit := false
|
||||||
|
|
||||||
|
if match := executeScopeWeekRangeRe.FindStringSubmatch(text); len(match) == 3 {
|
||||||
|
start, okStart := parseRegexInt(match[1])
|
||||||
|
end, okEnd := parseRegexInt(match[2])
|
||||||
|
if okStart && okEnd {
|
||||||
|
if start > end {
|
||||||
|
start, end = end, start
|
||||||
|
}
|
||||||
|
scope.HasWeek = true
|
||||||
|
scope.WeekFrom = start
|
||||||
|
scope.WeekTo = end
|
||||||
|
hit = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if match := executeScopeWeekSingleRe.FindStringSubmatch(text); len(match) == 2 {
|
||||||
|
week, okWeek := parseRegexInt(match[1])
|
||||||
|
if okWeek {
|
||||||
|
scope.HasWeek = true
|
||||||
|
scope.WeekFrom = week
|
||||||
|
scope.WeekTo = week
|
||||||
|
hit = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rangeStart, rangeEnd, okRange := parseExecuteScopeDayRange(text); okRange {
|
||||||
|
scope.HasDay = true
|
||||||
|
scope.DayStart = rangeStart
|
||||||
|
scope.DayEnd = rangeEnd
|
||||||
|
hit = true
|
||||||
|
} else {
|
||||||
|
dayMatches := executeScopeDaySingleRe.FindAllStringSubmatch(text, -1)
|
||||||
|
if len(dayMatches) == 1 && len(dayMatches[0]) == 2 {
|
||||||
|
day, okDay := parseRegexInt(dayMatches[0][1])
|
||||||
|
if okDay {
|
||||||
|
scope.HasDay = true
|
||||||
|
scope.DayStart = day
|
||||||
|
scope.DayEnd = day
|
||||||
|
hit = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for dayOfWeek := range parseExecuteScopeWeekdays(text) {
|
||||||
|
scope.DayOfWeekSet[dayOfWeek] = struct{}{}
|
||||||
|
hit = true
|
||||||
|
}
|
||||||
|
if len(scope.DayOfWeekSet) == 0 {
|
||||||
|
scope.DayOfWeekSet = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hit {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return scope, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseExecuteScopeDayRange(text string) (start int, end int, ok bool) {
|
||||||
|
if match := executeScopeDayRangeReA.FindStringSubmatch(text); len(match) == 3 {
|
||||||
|
startA, okA := parseRegexInt(match[1])
|
||||||
|
endA, okB := parseRegexInt(match[2])
|
||||||
|
if okA && okB {
|
||||||
|
if startA > endA {
|
||||||
|
startA, endA = endA, startA
|
||||||
|
}
|
||||||
|
return startA, endA, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if match := executeScopeDayRangeReB.FindStringSubmatch(text); len(match) == 3 {
|
||||||
|
startB, okA := parseRegexInt(match[1])
|
||||||
|
endB, okB := parseRegexInt(match[2])
|
||||||
|
if okA && okB {
|
||||||
|
if startB > endB {
|
||||||
|
startB, endB = endB, startB
|
||||||
|
}
|
||||||
|
return startB, endB, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseExecuteScopeWeekdays(text string) map[int]struct{} {
|
||||||
|
result := make(map[int]struct{}, 7)
|
||||||
|
compact := strings.TrimSpace(text)
|
||||||
|
if compact == "" {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, match := range executeScopeWeekdayRangeRe.FindAllStringSubmatch(compact, -1) {
|
||||||
|
if len(match) != 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
from, okFrom := normalizeChineseWeekday(match[1])
|
||||||
|
to, okTo := normalizeChineseWeekday(match[2])
|
||||||
|
if !okFrom || !okTo {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if from <= to {
|
||||||
|
for day := from; day <= to; day++ {
|
||||||
|
result[day] = struct{}{}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for day := from; day <= 7; day++ {
|
||||||
|
result[day] = struct{}{}
|
||||||
|
}
|
||||||
|
for day := 1; day <= to; day++ {
|
||||||
|
result[day] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result) == 0 {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(compact, "工作日"):
|
||||||
|
for day := 1; day <= 5; day++ {
|
||||||
|
result[day] = struct{}{}
|
||||||
|
}
|
||||||
|
case strings.Contains(compact, "周末"):
|
||||||
|
result[6] = struct{}{}
|
||||||
|
result[7] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result) == 0 {
|
||||||
|
matches := executeScopeWeekdayRe.FindAllStringSubmatch(compact, -1)
|
||||||
|
if len(matches) == 1 && len(matches[0]) == 2 {
|
||||||
|
if day, ok := normalizeChineseWeekday(matches[0][1]); ok {
|
||||||
|
result[day] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeChineseWeekday(raw string) (int, bool) {
|
||||||
|
switch strings.TrimSpace(raw) {
|
||||||
|
case "一":
|
||||||
|
return 1, true
|
||||||
|
case "二":
|
||||||
|
return 2, true
|
||||||
|
case "三":
|
||||||
|
return 3, true
|
||||||
|
case "四":
|
||||||
|
return 4, true
|
||||||
|
case "五":
|
||||||
|
return 5, true
|
||||||
|
case "六":
|
||||||
|
return 6, true
|
||||||
|
case "日", "天":
|
||||||
|
return 7, true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRegexInt(raw string) (int, bool) {
|
||||||
|
value, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderExecuteStepScope(scope *executeStepScope) string {
|
||||||
|
if scope == nil {
|
||||||
|
return "未设范围"
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, 3)
|
||||||
|
if scope.HasWeek {
|
||||||
|
if scope.WeekFrom == scope.WeekTo {
|
||||||
|
parts = append(parts, fmt.Sprintf("第%d周", scope.WeekFrom))
|
||||||
|
} else {
|
||||||
|
parts = append(parts, fmt.Sprintf("第%d-%d周", scope.WeekFrom, scope.WeekTo))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if scope.HasDay {
|
||||||
|
if scope.DayStart == scope.DayEnd {
|
||||||
|
parts = append(parts, fmt.Sprintf("第%d天", scope.DayStart))
|
||||||
|
} else {
|
||||||
|
parts = append(parts, fmt.Sprintf("第%d-%d天", scope.DayStart, scope.DayEnd))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(scope.DayOfWeekSet) > 0 {
|
||||||
|
weekdays := make([]string, 0, 7)
|
||||||
|
for _, day := range []int{1, 2, 3, 4, 5, 6, 7} {
|
||||||
|
if _, ok := scope.DayOfWeekSet[day]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
weekdays = append(weekdays, fmt.Sprintf("周%d", day))
|
||||||
|
}
|
||||||
|
if len(weekdays) > 0 {
|
||||||
|
parts = append(parts, strings.Join(weekdays, "/"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return "未设范围"
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildScopeDaySet(state *newagenttools.ScheduleState, scope *executeStepScope) map[int]struct{} {
|
||||||
|
result := make(map[int]struct{}, 16)
|
||||||
|
if state == nil || scope == nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
for day := 1; day <= state.Window.TotalDays; day++ {
|
||||||
|
if dayMatchesScope(state, scope, day) {
|
||||||
|
result[day] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func dayMatchesScope(state *newagenttools.ScheduleState, scope *executeStepScope, day int) bool {
|
||||||
|
if state == nil || scope == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if day < 1 || day > state.Window.TotalDays {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
week, dayOfWeek, ok := state.DayToWeekDay(day)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if scope.HasWeek && (week < scope.WeekFrom || week > scope.WeekTo) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if scope.HasDay && (day < scope.DayStart || day > scope.DayEnd) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(scope.DayOfWeekSet) > 0 {
|
||||||
|
if _, matched := scope.DayOfWeekSet[dayOfWeek]; !matched {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func estimateCandidateDaysFromArgs(state *newagenttools.ScheduleState, args map[string]any) (map[int]struct{}, bool, error) {
|
||||||
|
result := make(map[int]struct{}, 16)
|
||||||
|
if state == nil {
|
||||||
|
return result, false, fmt.Errorf("日程状态为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
day, hasDay := readIntAnyFromMap(args, "day")
|
||||||
|
dayStart, hasDayStart := readIntAnyFromMap(args, "day_start")
|
||||||
|
dayEnd, hasDayEnd := readIntAnyFromMap(args, "day_end")
|
||||||
|
if hasDay && (hasDayStart || hasDayEnd) {
|
||||||
|
return nil, true, fmt.Errorf("day 与 day_start/day_end 不能同时传入")
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasDay && (day < 1 || day > state.Window.TotalDays) {
|
||||||
|
return nil, true, fmt.Errorf("day=%d 超出窗口范围(1-%d)", day, state.Window.TotalDays)
|
||||||
|
}
|
||||||
|
if hasDayStart && (dayStart < 1 || dayStart > state.Window.TotalDays) {
|
||||||
|
return nil, true, fmt.Errorf("day_start=%d 超出窗口范围(1-%d)", dayStart, state.Window.TotalDays)
|
||||||
|
}
|
||||||
|
if hasDayEnd && (dayEnd < 1 || dayEnd > state.Window.TotalDays) {
|
||||||
|
return nil, true, fmt.Errorf("day_end=%d 超出窗口范围(1-%d)", dayEnd, state.Window.TotalDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := 1
|
||||||
|
end := state.Window.TotalDays
|
||||||
|
if hasDay {
|
||||||
|
start, end = day, day
|
||||||
|
} else {
|
||||||
|
if hasDayStart {
|
||||||
|
start = dayStart
|
||||||
|
}
|
||||||
|
if hasDayEnd {
|
||||||
|
end = dayEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start > end {
|
||||||
|
return nil, true, fmt.Errorf("day_start=%d 不能大于 day_end=%d", start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
week, hasWeek := readIntAnyFromMap(args, "week")
|
||||||
|
weekFrom, hasWeekFrom := readIntAnyFromMap(args, "week_from")
|
||||||
|
weekTo, hasWeekTo := readIntAnyFromMap(args, "week_to")
|
||||||
|
if hasWeek {
|
||||||
|
weekFrom, weekTo = week, week
|
||||||
|
hasWeekFrom, hasWeekTo = true, true
|
||||||
|
}
|
||||||
|
if hasWeekFrom && hasWeekTo && weekFrom > weekTo {
|
||||||
|
weekFrom, weekTo = weekTo, weekFrom
|
||||||
|
}
|
||||||
|
weekFilter := intSliceToSet(readIntSliceAnyFromMap(args, "week_filter"))
|
||||||
|
|
||||||
|
dayOfWeekSet := intSliceToSet(readIntSliceAnyFromMap(args, "day_of_week"))
|
||||||
|
dayScope := strings.ToLower(strings.TrimSpace(readStringAnyFromMap(args, "day_scope")))
|
||||||
|
if dayScope == "" {
|
||||||
|
dayScope = "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
hasCalendarFilter := hasAnyCalendarArg(args)
|
||||||
|
for dayIndex := start; dayIndex <= end; dayIndex++ {
|
||||||
|
weekValue, dayOfWeek, ok := state.DayToWeekDay(dayIndex)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hasWeekFrom && weekValue < weekFrom {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hasWeekTo && weekValue > weekTo {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(weekFilter) > 0 {
|
||||||
|
if _, hit := weekFilter[weekValue]; !hit {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(dayOfWeekSet) > 0 {
|
||||||
|
if _, hit := dayOfWeekSet[dayOfWeek]; !hit {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if !matchDayScopeForGuard(dayOfWeek, dayScope) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[dayIndex] = struct{}{}
|
||||||
|
}
|
||||||
|
return result, hasCalendarFilter, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchDayScopeForGuard(dayOfWeek int, scope string) bool {
|
||||||
|
switch scope {
|
||||||
|
case "workday":
|
||||||
|
return dayOfWeek >= 1 && dayOfWeek <= 5
|
||||||
|
case "weekend":
|
||||||
|
return dayOfWeek == 6 || dayOfWeek == 7
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasAnyCalendarArg(args map[string]any) bool {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
keys := []string{"day", "day_start", "day_end", "week", "week_from", "week_to", "week_filter", "day_of_week", "day_scope"}
|
||||||
|
for _, key := range keys {
|
||||||
|
if _, exists := args[key]; exists {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractBatchMoveNewDays(args map[string]any) ([]int, error) {
|
||||||
|
rawMoves, exists := args["moves"]
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("缺少 moves")
|
||||||
|
}
|
||||||
|
list, ok := rawMoves.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("moves 不是数组")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]int, 0, len(list))
|
||||||
|
for _, item := range list {
|
||||||
|
moveMap, ok := item.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newDay, hasDay := readIntAnyFromMap(moveMap, "new_day")
|
||||||
|
if !hasDay {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, newDay)
|
||||||
|
}
|
||||||
|
if len(result) == 0 {
|
||||||
|
return nil, fmt.Errorf("moves 未提供有效 new_day")
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func intSliceToSet(values []int) map[int]struct{} {
|
||||||
|
result := make(map[int]struct{}, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
result[value] = struct{}{}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func readIntAnyFromMap(args map[string]any, keys ...string) (int, bool) {
|
||||||
|
for _, key := range keys {
|
||||||
|
if args == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, exists := args[key]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if value, ok := parseAnyToInt(raw); ok {
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func readIntSliceAnyFromMap(args map[string]any, keys ...string) []int {
|
||||||
|
for _, key := range keys {
|
||||||
|
if args == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, exists := args[key]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
values := parseAnyToIntSlice(raw)
|
||||||
|
if len(values) > 0 {
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readStringAnyFromMap(args map[string]any, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
if args == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, exists := args[key]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if text, ok := raw.(string); ok {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAnyToInt(value any) (int, bool) {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case int:
|
||||||
|
return v, true
|
||||||
|
case int8:
|
||||||
|
return int(v), true
|
||||||
|
case int16:
|
||||||
|
return int(v), true
|
||||||
|
case int32:
|
||||||
|
return int(v), true
|
||||||
|
case int64:
|
||||||
|
return int(v), true
|
||||||
|
case float32:
|
||||||
|
return int(v), true
|
||||||
|
case float64:
|
||||||
|
return int(v), true
|
||||||
|
case json.Number:
|
||||||
|
if iv, err := v.Int64(); err == nil {
|
||||||
|
return int(iv), true
|
||||||
|
}
|
||||||
|
if fv, err := v.Float64(); err == nil {
|
||||||
|
return int(fv), true
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
text := strings.TrimSpace(v)
|
||||||
|
if text == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
iv, err := strconv.Atoi(text)
|
||||||
|
if err == nil {
|
||||||
|
return iv, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAnyToIntSlice(value any) []int {
|
||||||
|
switch values := value.(type) {
|
||||||
|
case []int:
|
||||||
|
result := make([]int, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
case []any:
|
||||||
|
result := make([]int, 0, len(values))
|
||||||
|
for _, item := range values {
|
||||||
|
iv, ok := parseAnyToInt(item)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, iv)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendToolCallResultHistory 统一把“assistant tool_call + tool observation”写回历史。
|
||||||
|
//
|
||||||
|
// 设计说明:
|
||||||
|
// 1. 采用标准配对消息格式,兼容 OpenAI tool_call 约束;
|
||||||
|
// 2. args 序列化失败时降级为 "{}",保证消息结构完整;
|
||||||
|
// 3. 仅负责写历史,不负责工具执行或状态更新。
|
||||||
|
func appendToolCallResultHistory(
|
||||||
|
conversationContext *newagentmodel.ConversationContext,
|
||||||
|
toolName string,
|
||||||
|
args map[string]any,
|
||||||
|
result string,
|
||||||
|
) {
|
||||||
|
if conversationContext == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
argsJSON := "{}"
|
||||||
|
if args != nil {
|
||||||
|
if raw, err := json.Marshal(args); err == nil {
|
||||||
|
argsJSON = string(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toolCallID := uuid.NewString()
|
||||||
|
conversationContext.AppendHistory(&schema.Message{
|
||||||
|
Role: schema.Assistant,
|
||||||
|
Content: "",
|
||||||
|
ToolCalls: []schema.ToolCall{
|
||||||
|
{
|
||||||
|
ID: toolCallID,
|
||||||
|
Type: "function",
|
||||||
|
Function: schema.FunctionCall{
|
||||||
|
Name: toolName,
|
||||||
|
Arguments: argsJSON,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
conversationContext.AppendHistory(&schema.Message{
|
||||||
|
Role: schema.Tool,
|
||||||
|
Content: result,
|
||||||
|
ToolCallID: toolCallID,
|
||||||
|
ToolName: toolName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// executeToolCall 执行工具调用并记录证据。
|
// executeToolCall 执行工具调用并记录证据。
|
||||||
//
|
//
|
||||||
// 职责边界:
|
// 职责边界:
|
||||||
@@ -771,34 +1396,7 @@ func executeToolCall(
|
|||||||
blockedResult,
|
blockedResult,
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, blockedResult)
|
||||||
toolCallID := uuid.NewString()
|
|
||||||
argsJSON := "{}"
|
|
||||||
if toolCall.Arguments != nil {
|
|
||||||
if raw, marshalErr := json.Marshal(toolCall.Arguments); marshalErr == nil {
|
|
||||||
argsJSON = string(raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
conversationContext.AppendHistory(&schema.Message{
|
|
||||||
Role: schema.Assistant,
|
|
||||||
Content: "",
|
|
||||||
ToolCalls: []schema.ToolCall{
|
|
||||||
{
|
|
||||||
ID: toolCallID,
|
|
||||||
Type: "function",
|
|
||||||
Function: schema.FunctionCall{
|
|
||||||
Name: toolName,
|
|
||||||
Arguments: argsJSON,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
conversationContext.AppendHistory(&schema.Message{
|
|
||||||
Role: schema.Tool,
|
|
||||||
Content: blockedResult,
|
|
||||||
ToolCallID: toolCallID,
|
|
||||||
ToolName: toolName,
|
|
||||||
})
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -816,42 +1414,8 @@ func executeToolCall(
|
|||||||
flattenForLog(result),
|
flattenForLog(result),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 3. 将工具调用和结果以合法的 assistant+tool 消息对追加到对话历史。
|
// 3. 以标准 assistant+tool 消息对写回历史,避免消息链断裂。
|
||||||
//
|
appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, result)
|
||||||
// 修复说明:
|
|
||||||
// 旧实现直接追加裸 Tool 消息(无 ToolCallID、无前置 assistant tool_calls),
|
|
||||||
// 违反 OpenAI 兼容 API 消息格式约束,导致 API 拒绝请求、连接断开。
|
|
||||||
// 正确做法:先追加带 ToolCalls 的 assistant 消息,再追加带匹配 ToolCallID 的 tool 消息。
|
|
||||||
toolCallID := uuid.NewString()
|
|
||||||
|
|
||||||
argsJSON := "{}"
|
|
||||||
if toolCall.Arguments != nil {
|
|
||||||
if raw, err := json.Marshal(toolCall.Arguments); err == nil {
|
|
||||||
argsJSON = string(raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
conversationContext.AppendHistory(&schema.Message{
|
|
||||||
Role: schema.Assistant,
|
|
||||||
Content: "",
|
|
||||||
ToolCalls: []schema.ToolCall{
|
|
||||||
{
|
|
||||||
ID: toolCallID,
|
|
||||||
Type: "function",
|
|
||||||
Function: schema.FunctionCall{
|
|
||||||
Name: toolName,
|
|
||||||
Arguments: argsJSON,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
conversationContext.AppendHistory(&schema.Message{
|
|
||||||
Role: schema.Tool,
|
|
||||||
Content: result,
|
|
||||||
ToolCallID: toolCallID,
|
|
||||||
ToolName: toolName,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 4. 写工具实时预览:每次写工具执行后都尝试刷新 Redis 预览,确保前端可见“最新操作结果”。
|
// 4. 写工具实时预览:每次写工具执行后都尝试刷新 Redis 预览,确保前端可见“最新操作结果”。
|
||||||
//
|
//
|
||||||
@@ -922,12 +1486,27 @@ func executePendingTool(
|
|||||||
if scheduleState == nil {
|
if scheduleState == nil {
|
||||||
return fmt.Errorf("日程状态未加载,无法执行已确认的写工具 %s", pending.ToolName)
|
return fmt.Errorf("日程状态未加载,无法执行已确认的写工具 %s", pending.ToolName)
|
||||||
}
|
}
|
||||||
|
flowState := runtimeState.EnsureCommonState()
|
||||||
|
|
||||||
|
// 3.1 顺序护栏在确认执行路径同样生效,避免绕过前置约束。
|
||||||
|
if shouldBlockMinContextSwitch(flowState, pending.ToolName) {
|
||||||
|
blockedResult := "已拒绝执行 min_context_switch:当前未授权打乱顺序。如需使用该工具,请先由用户明确说明“允许打乱顺序”。"
|
||||||
|
_ = emitter.EmitStatus(
|
||||||
|
executeStatusBlockID,
|
||||||
|
executeStageName,
|
||||||
|
"tool_blocked",
|
||||||
|
blockedResult,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
appendToolCallResultHistory(conversationContext, pending.ToolName, args, blockedResult)
|
||||||
|
runtimeState.PendingConfirmTool = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// 4. 执行工具。
|
// 4. 执行工具。
|
||||||
beforeDigest := summarizeScheduleStateForDebug(scheduleState)
|
beforeDigest := summarizeScheduleStateForDebug(scheduleState)
|
||||||
result := registry.Execute(scheduleState, pending.ToolName, args)
|
result := registry.Execute(scheduleState, pending.ToolName, args)
|
||||||
afterDigest := summarizeScheduleStateForDebug(scheduleState)
|
afterDigest := summarizeScheduleStateForDebug(scheduleState)
|
||||||
flowState := runtimeState.EnsureCommonState()
|
|
||||||
log.Printf(
|
log.Printf(
|
||||||
"[DEBUG] execute pending tool chat=%s round=%d tool=%s args=%s before=%s after=%s result_preview=%.200s",
|
"[DEBUG] execute pending tool chat=%s round=%d tool=%s args=%s before=%s after=%s result_preview=%.200s",
|
||||||
flowState.ConversationID,
|
flowState.ConversationID,
|
||||||
@@ -939,32 +1518,8 @@ func executePendingTool(
|
|||||||
flattenForLog(result),
|
flattenForLog(result),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 5. 将工具调用和结果以合法的 assistant+tool 消息对追加到历史。
|
// 5. 将工具调用和结果写回历史,维持标准 tool_call 配对格式。
|
||||||
//
|
appendToolCallResultHistory(conversationContext, pending.ToolName, args, result)
|
||||||
// 修复说明:同 executeToolCall,需要配对的 assistant+tool 消息。
|
|
||||||
toolCallID := uuid.NewString()
|
|
||||||
|
|
||||||
conversationContext.AppendHistory(&schema.Message{
|
|
||||||
Role: schema.Assistant,
|
|
||||||
Content: "",
|
|
||||||
ToolCalls: []schema.ToolCall{
|
|
||||||
{
|
|
||||||
ID: toolCallID,
|
|
||||||
Type: "function",
|
|
||||||
Function: schema.FunctionCall{
|
|
||||||
Name: pending.ToolName,
|
|
||||||
Arguments: pending.ArgsJSON,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
conversationContext.AppendHistory(&schema.Message{
|
|
||||||
Role: schema.Tool,
|
|
||||||
Content: result,
|
|
||||||
ToolCallID: toolCallID,
|
|
||||||
ToolName: pending.ToolName,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 5. 写工具实时预览:confirm accept 后真实执行写工具时,立即刷新一次预览缓存。
|
// 5. 写工具实时预览:confirm accept 后真实执行写工具时,立即刷新一次预览缓存。
|
||||||
tryWritePreviewAfterWriteTool(ctx, flowState, scheduleState, registry, pending.ToolName, writePreview)
|
tryWritePreviewAfterWriteTool(ctx, flowState, scheduleState, registry, pending.ToolName, writePreview)
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ const chatRoutingSystemPrompt = `
|
|||||||
- plan:用户明确要求先制定计划,或涉及多阶段复杂规划。speak 写确认语。
|
- plan:用户明确要求先制定计划,或涉及多阶段复杂规划。speak 写确认语。
|
||||||
|
|
||||||
粗排判断:当用户意图包含"批量安排/排课/把任务类排进日程",且上下文中有任务类 ID 时,设置 needs_rough_build=true。
|
粗排判断:当用户意图包含"批量安排/排课/把任务类排进日程",且上下文中有任务类 ID 时,设置 needs_rough_build=true。
|
||||||
|
二次粗排约束(强约束):
|
||||||
|
- 若上下文已出现 rough_build_done,且用户未明确要求“重新粗排/从头重排”,必须设置 needs_rough_build=false。
|
||||||
|
- “移动/微调/优化/均匀化/调顺序”等请求默认视为 refine,不得再次触发 rough build。
|
||||||
粗排后微调判断:
|
粗排后微调判断:
|
||||||
- 仅当 needs_rough_build=true 时才判断 needs_refine_after_rough_build。
|
- 仅当 needs_rough_build=true 时才判断 needs_refine_after_rough_build。
|
||||||
- 若用户明确提出优化目标/偏好(如"尽量均衡""周三别太满""某门课往后挪"),设 needs_refine_after_rough_build=true。
|
- 若用户明确提出优化目标/偏好(如"尽量均衡""周三别太满""某门课往后挪"),设 needs_refine_after_rough_build=true。
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const executeSystemPromptWithPlan = `
|
|||||||
11. 若当前顺序策略是“默认保持顺序”,禁止调用 min_context_switch。
|
11. 若当前顺序策略是“默认保持顺序”,禁止调用 min_context_switch。
|
||||||
12. 不要把超过 2 条任务打包到 batch_move;大批量调整请改走队列逐项处理。
|
12. 不要把超过 2 条任务打包到 batch_move;大批量调整请改走队列逐项处理。
|
||||||
13. 不要在未获取队首(queue_pop_head)时直接调用 queue_apply_head_move。
|
13. 不要在未获取队首(queue_pop_head)时直接调用 queue_apply_head_move。
|
||||||
|
14. 工具参数必须严格使用 schema 字段,禁止自造别名;例如 day_from/day_to 非法,必须改用 day_start/day_end。
|
||||||
|
|
||||||
执行规则:
|
执行规则:
|
||||||
1. 只输出严格 JSON,不要输出 markdown,不要在 JSON 外补充文本。
|
1. 只输出严格 JSON,不要输出 markdown,不要在 JSON 外补充文本。
|
||||||
@@ -73,6 +74,7 @@ const executeSystemPromptReAct = `
|
|||||||
10. 若顺序策略为“保持顺序”,禁止调用 min_context_switch。
|
10. 若顺序策略为“保持顺序”,禁止调用 min_context_switch。
|
||||||
11. 不要在同一轮构造大规模 batch_move;batch_move 最多 2 条,超过请走队列逐项处理。
|
11. 不要在同一轮构造大规模 batch_move;batch_move 最多 2 条,超过请走队列逐项处理。
|
||||||
12. 未调用 queue_pop_head 获取 current 前,不要调用 queue_apply_head_move。
|
12. 未调用 queue_pop_head 获取 current 前,不要调用 queue_apply_head_move。
|
||||||
|
13. 工具参数必须严格使用 schema 字段,禁止自造别名;例如 day_from/day_to 非法,必须改用 day_start/day_end。
|
||||||
|
|
||||||
执行规则:
|
执行规则:
|
||||||
1. 只输出严格 JSON,不要输出 markdown,不要在 JSON 外补充文本。
|
1. 只输出严格 JSON,不要输出 markdown,不要在 JSON 外补充文本。
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const (
|
|||||||
executeHistoryKindKey = "newagent_history_kind"
|
executeHistoryKindKey = "newagent_history_kind"
|
||||||
executeHistoryKindCorrectionUser = "llm_correction_prompt"
|
executeHistoryKindCorrectionUser = "llm_correction_prompt"
|
||||||
executeHistoryKindLoopClosed = "execute_loop_closed"
|
executeHistoryKindLoopClosed = "execute_loop_closed"
|
||||||
|
executeHistoryKindStepAdvanced = "execute_step_advanced"
|
||||||
|
|
||||||
// executeLoopWindowLimit 控制“当轮 ReAct Loop 窗口”最多保留多少条记录。
|
// executeLoopWindowLimit 控制“当轮 ReAct Loop 窗口”最多保留多少条记录。
|
||||||
// 采用固定窗口能避免上下文无上限增长,且可保持“最近行为”可追踪。
|
// 采用固定窗口能避免上下文无上限增长,且可保持“最近行为”可追踪。
|
||||||
@@ -217,7 +218,7 @@ func splitExecuteLoopRecordsByBoundary(history []*schema.Message) (archived []ex
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
boundary := findLatestExecuteLoopClosedMarker(history)
|
boundary := findLatestExecuteBoundaryMarker(history)
|
||||||
if boundary < 0 {
|
if boundary < 0 {
|
||||||
return nil, collectExecuteLoopRecords(history)
|
return nil, collectExecuteLoopRecords(history)
|
||||||
}
|
}
|
||||||
@@ -231,7 +232,7 @@ func splitExecuteLoopRecordsByBoundary(history []*schema.Message) (archived []ex
|
|||||||
return archived, active
|
return archived, active
|
||||||
}
|
}
|
||||||
|
|
||||||
func findLatestExecuteLoopClosedMarker(history []*schema.Message) int {
|
func findLatestExecuteBoundaryMarker(history []*schema.Message) int {
|
||||||
for i := len(history) - 1; i >= 0; i-- {
|
for i := len(history) - 1; i >= 0; i-- {
|
||||||
msg := history[i]
|
msg := history[i]
|
||||||
if msg == nil || msg.Extra == nil {
|
if msg == nil || msg.Extra == nil {
|
||||||
@@ -241,7 +242,8 @@ func findLatestExecuteLoopClosedMarker(history []*schema.Message) int {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(kind) == executeHistoryKindLoopClosed {
|
switch strings.TrimSpace(kind) {
|
||||||
|
case executeHistoryKindLoopClosed, executeHistoryKindStepAdvanced:
|
||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -407,8 +409,9 @@ func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.C
|
|||||||
lines = append(lines, "- 啥时候结束Loop:你可以根据工具调用记录自行判断。")
|
lines = append(lines, "- 啥时候结束Loop:你可以根据工具调用记录自行判断。")
|
||||||
lines = append(lines, "- 非目标:不重新粗排、不修改无关任务类。")
|
lines = append(lines, "- 非目标:不重新粗排、不修改无关任务类。")
|
||||||
if hasExecuteRoughBuildDone(ctx) {
|
if hasExecuteRoughBuildDone(ctx) {
|
||||||
lines = append(lines, "- 阶段约束:粗排已完成,本轮只微调 suggested;existing 仅作已安排事实参考,不做 move/batch_move/spread_even。")
|
lines = append(lines, "- 阶段约束:粗排已完成,本轮只微调 suggested;existing 仅作已安排事实参考,不作为可移动目标。")
|
||||||
}
|
}
|
||||||
|
lines = append(lines, "- 参数纪律:工具参数必须严格使用 schema 字段;若返回“参数非法”,需先改参再继续。")
|
||||||
if state != nil {
|
if state != nil {
|
||||||
if state.AllowReorder {
|
if state.AllowReorder {
|
||||||
lines = append(lines, "- 顺序策略:用户已明确允许打乱顺序,可在必要时使用 min_context_switch。")
|
lines = append(lines, "- 顺序策略:用户已明确允许打乱顺序,可在必要时使用 min_context_switch。")
|
||||||
|
|||||||
61
backend/newAgent/tools/arg_guard.go
Normal file
61
backend/newAgent/tools/arg_guard.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package newagenttools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validateToolArgsStrict 校验工具参数是否全部命中 schema 白名单。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只做“字段名是否允许”的校验,不校验字段值合法性;
|
||||||
|
// 2. 发现未知字段时直接报错,避免静默忽略导致范围漂移;
|
||||||
|
// 3. 该函数不做别名兼容,调用方应自行传入 schema 中允许的字段。
|
||||||
|
func validateToolArgsStrict(args map[string]any, allowedKeys []string) error {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
allowed := make(map[string]struct{}, len(allowedKeys))
|
||||||
|
for _, key := range allowedKeys {
|
||||||
|
allowed[strings.TrimSpace(key)] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
unknown := make([]string, 0, len(args))
|
||||||
|
for key := range args {
|
||||||
|
trimmed := strings.TrimSpace(key)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := allowed[trimmed]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
unknown = append(unknown, trimmed)
|
||||||
|
}
|
||||||
|
if len(unknown) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(unknown)
|
||||||
|
hint := "请仅使用当前工具 schema 中声明的参数字段。"
|
||||||
|
if containsAnyUnknownArg(unknown, "day_from", "day_to") {
|
||||||
|
hint = "请仅使用当前工具 schema 中声明的参数字段;day_from/day_to 不受支持,请改用 day_start/day_end。"
|
||||||
|
}
|
||||||
|
return fmt.Errorf("参数非法:%s。%s", strings.Join(unknown, "、"), hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAnyUnknownArg(keys []string, targets ...string) bool {
|
||||||
|
if len(keys) == 0 || len(targets) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
targetSet := make(map[string]struct{}, len(targets))
|
||||||
|
for _, target := range targets {
|
||||||
|
targetSet[strings.TrimSpace(target)] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, key := range keys {
|
||||||
|
if _, ok := targetSet[strings.TrimSpace(key)]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -9,6 +9,27 @@ import (
|
|||||||
compositelogic "github.com/LoveLosita/smartflow/backend/logic"
|
compositelogic "github.com/LoveLosita/smartflow/backend/logic"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var spreadEvenAllowedArgs = []string{
|
||||||
|
"task_ids",
|
||||||
|
"task_id",
|
||||||
|
"limit",
|
||||||
|
"allow_embed",
|
||||||
|
"day",
|
||||||
|
"day_start",
|
||||||
|
"day_end",
|
||||||
|
"day_scope",
|
||||||
|
"day_of_week",
|
||||||
|
"week",
|
||||||
|
"week_filter",
|
||||||
|
"week_from",
|
||||||
|
"week_to",
|
||||||
|
"slot_type",
|
||||||
|
"slot_types",
|
||||||
|
"exclude_sections",
|
||||||
|
"after_section",
|
||||||
|
"before_section",
|
||||||
|
}
|
||||||
|
|
||||||
// minContextSnapshot 记录任务在复合重排前后的最小快照,用于输出摘要。
|
// minContextSnapshot 记录任务在复合重排前后的最小快照,用于输出摘要。
|
||||||
type minContextSnapshot struct {
|
type minContextSnapshot struct {
|
||||||
StateID int
|
StateID int
|
||||||
@@ -177,6 +198,10 @@ func SpreadEven(state *ScheduleState, taskIDs []int, args map[string]any) string
|
|||||||
if state == nil {
|
if state == nil {
|
||||||
return "均匀化调整失败:日程状态为空。"
|
return "均匀化调整失败:日程状态为空。"
|
||||||
}
|
}
|
||||||
|
// 0. 参数白名单校验:未知字段直接失败,避免静默忽略导致候选范围漂移。
|
||||||
|
if err := validateToolArgsStrict(args, spreadEvenAllowedArgs); err != nil {
|
||||||
|
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 先做任务侧校验,避免后续规划在脏输入上执行。
|
// 1. 先做任务侧校验,避免后续规划在脏输入上执行。
|
||||||
plannerTasks, beforeByID, excludeIDs, idMapper, err := collectCompositePlannerTasks(state, taskIDs, "均匀化调整")
|
plannerTasks, beforeByID, excludeIDs, idMapper, err := collectCompositePlannerTasks(state, taskIDs, "均匀化调整")
|
||||||
|
|||||||
@@ -112,6 +112,51 @@ type queryTargetOptions struct {
|
|||||||
ResetQueue bool
|
ResetQueue bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
queryAvailableAllowedArgs = []string{
|
||||||
|
"span",
|
||||||
|
"duration",
|
||||||
|
"limit",
|
||||||
|
"allow_embed",
|
||||||
|
"day",
|
||||||
|
"day_start",
|
||||||
|
"day_end",
|
||||||
|
"day_scope",
|
||||||
|
"day_of_week",
|
||||||
|
"week",
|
||||||
|
"week_filter",
|
||||||
|
"week_from",
|
||||||
|
"week_to",
|
||||||
|
"slot_type",
|
||||||
|
"slot_types",
|
||||||
|
"exclude_sections",
|
||||||
|
"after_section",
|
||||||
|
"before_section",
|
||||||
|
"section_from",
|
||||||
|
"section_to",
|
||||||
|
}
|
||||||
|
queryTargetAllowedArgs = []string{
|
||||||
|
"status",
|
||||||
|
"category",
|
||||||
|
"limit",
|
||||||
|
"day_scope",
|
||||||
|
"day",
|
||||||
|
"day_start",
|
||||||
|
"day_end",
|
||||||
|
"day_of_week",
|
||||||
|
"week",
|
||||||
|
"week_filter",
|
||||||
|
"week_from",
|
||||||
|
"week_to",
|
||||||
|
"task_ids",
|
||||||
|
"task_id",
|
||||||
|
"task_item_ids",
|
||||||
|
"task_item_id",
|
||||||
|
"enqueue",
|
||||||
|
"reset_queue",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// QueryAvailableSlots 返回“候选坑位池”。
|
// QueryAvailableSlots 返回“候选坑位池”。
|
||||||
//
|
//
|
||||||
// 职责边界:
|
// 职责边界:
|
||||||
@@ -119,6 +164,11 @@ type queryTargetOptions struct {
|
|||||||
// 2. 优先返回纯空位(strict),不足时再补可嵌入位(embedded);
|
// 2. 优先返回纯空位(strict),不足时再补可嵌入位(embedded);
|
||||||
// 3. 不负责移动策略决策,最终落点由模型结合目标再选择。
|
// 3. 不负责移动策略决策,最终落点由模型结合目标再选择。
|
||||||
func QueryAvailableSlots(state *ScheduleState, args map[string]any) string {
|
func QueryAvailableSlots(state *ScheduleState, args map[string]any) string {
|
||||||
|
// 0. 先做字段白名单校验:未知参数直接报错,避免静默忽略造成范围漂移。
|
||||||
|
if err := validateToolArgsStrict(args, queryAvailableAllowedArgs); err != nil {
|
||||||
|
return fmt.Sprintf(`{"tool":"query_available_slots","success":false,"error":"%s"}`, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 解析参数并做合法性校验。
|
// 1. 解析参数并做合法性校验。
|
||||||
options, err := parseQueryAvailableOptions(state, args)
|
options, err := parseQueryAvailableOptions(state, args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -220,6 +270,11 @@ func QueryAvailableSlots(state *ScheduleState, args map[string]any) string {
|
|||||||
// 2. 默认 status=suggested,减少模型误选 existing/pending;
|
// 2. 默认 status=suggested,减少模型误选 existing/pending;
|
||||||
// 3. 仅返回状态事实,不做“该不该移动”的语义判断。
|
// 3. 仅返回状态事实,不做“该不该移动”的语义判断。
|
||||||
func QueryTargetTasks(state *ScheduleState, args map[string]any) string {
|
func QueryTargetTasks(state *ScheduleState, args map[string]any) string {
|
||||||
|
// 0. 先做字段白名单校验:未知参数直接报错,避免静默忽略造成范围漂移。
|
||||||
|
if err := validateToolArgsStrict(args, queryTargetAllowedArgs); err != nil {
|
||||||
|
return fmt.Sprintf(`{"tool":"query_target_tasks","success":false,"error":"%s"}`, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 解析参数。
|
// 1. 解析参数。
|
||||||
options, err := parseQueryTargetOptions(state, args)
|
options, err := parseQueryTargetOptions(state, args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ import (
|
|||||||
eventsvc "github.com/LoveLosita/smartflow/backend/service/events"
|
eventsvc "github.com/LoveLosita/smartflow/backend/service/events"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
newAgentHistoryKindKey = "newagent_history_kind"
|
||||||
|
newAgentHistoryKindLoopClosed = "execute_loop_closed"
|
||||||
|
)
|
||||||
|
|
||||||
// runNewAgentGraph 运行 newAgent 通用 graph,直接替换旧 agent 路由逻辑。
|
// runNewAgentGraph 运行 newAgent 通用 graph,直接替换旧 agent 路由逻辑。
|
||||||
//
|
//
|
||||||
// 职责边界:
|
// 职责边界:
|
||||||
@@ -252,6 +257,12 @@ func (s *AgentService) loadOrCreateRuntimeState(ctx context.Context, chatID stri
|
|||||||
if !snapshot.RuntimeState.HasPendingInteraction() && cs.Phase == newagentmodel.PhaseDone {
|
if !snapshot.RuntimeState.HasPendingInteraction() && cs.Phase == newagentmodel.PhaseDone {
|
||||||
terminalBefore := cs.TerminalStatus()
|
terminalBefore := cs.TerminalStatus()
|
||||||
roundBefore := cs.RoundUsed
|
roundBefore := cs.RoundUsed
|
||||||
|
// 1. 仅“正常完成(completed)”写 loop 收口 marker:
|
||||||
|
// 1.1 下一轮执行时,prompt 会把上一轮 loop 从 msg2 归档到 msg1;
|
||||||
|
// 1.2 异常中断(aborted/exhausted)不写 marker,保留 msg2 便于后续续跑。
|
||||||
|
if terminalBefore == newagentmodel.FlowTerminalStatusCompleted {
|
||||||
|
appendExecuteLoopClosedMarker(snapshot.ConversationContext)
|
||||||
|
}
|
||||||
cs.ResetForNextRun()
|
cs.ResetForNextRun()
|
||||||
log.Printf(
|
log.Printf(
|
||||||
"[DEBUG] loadOrCreateRuntimeState reset runtime for next run chat=%s round_before=%d terminal_before=%s",
|
"[DEBUG] loadOrCreateRuntimeState reset runtime for next run chat=%s round_before=%d terminal_before=%s",
|
||||||
@@ -276,6 +287,35 @@ func (s *AgentService) loadOrCreateRuntimeState(ctx context.Context, chatID stri
|
|||||||
return newRT()
|
return newRT()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// appendExecuteLoopClosedMarker 在 ConversationContext 写入“上一轮 loop 正常收口”标记。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只追加轻量 marker 供 prompt 分层,不做历史摘要或裁剪;
|
||||||
|
// 2. 若末尾已是同类 marker,则幂等跳过;
|
||||||
|
// 3. context 为空时直接返回,避免冷启动异常。
|
||||||
|
func appendExecuteLoopClosedMarker(conversationContext *newagentmodel.ConversationContext) {
|
||||||
|
if conversationContext == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
history := conversationContext.HistorySnapshot()
|
||||||
|
if len(history) > 0 {
|
||||||
|
last := history[len(history)-1]
|
||||||
|
if last != nil && last.Extra != nil {
|
||||||
|
if kind, ok := last.Extra[newAgentHistoryKindKey].(string); ok && strings.TrimSpace(kind) == newAgentHistoryKindLoopClosed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conversationContext.AppendHistory(&schema.Message{
|
||||||
|
Role: schema.Assistant,
|
||||||
|
Content: "",
|
||||||
|
Extra: map[string]any{
|
||||||
|
newAgentHistoryKindKey: newAgentHistoryKindLoopClosed,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// loadConversationContext 加载对话历史,构造 ConversationContext。
|
// loadConversationContext 加载对话历史,构造 ConversationContext。
|
||||||
func (s *AgentService) loadConversationContext(ctx context.Context, chatID, userMessage string) *newagentmodel.ConversationContext {
|
func (s *AgentService) loadConversationContext(ctx context.Context, chatID, userMessage string) *newagentmodel.ConversationContext {
|
||||||
// 从 Redis 加载历史。
|
// 从 Redis 加载历史。
|
||||||
|
|||||||
Reference in New Issue
Block a user