Version: 0.9.75.dev.260505
后端: 1.收口阶段 6 agent 结构迁移,将 newAgent 内核与 agentsvc 编排层迁入 services/agent - 切换 Agent 启动装配与 HTTP handler 直连 agent sv,移除旧 service agent bridge - 补齐 Agent 对 memory、task、task-class、schedule 的 RPC 适配与契约字段 - 扩展 schedule、task、task-class RPC/contract 支撑 Agent 查询、写入与 provider 切流 - 更新迁移文档、README 与相关注释,明确 agent 当前切流点和剩余 memory 迁移面
This commit is contained in:
241
backend/services/agent/prompt/base.go
Normal file
241
backend/services/agent/prompt/base.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// buildStageMessages 组装某个阶段通用的 messages。
|
||||
//
|
||||
// 消息排列策略(利用 LLM 近因效应):
|
||||
// 1. system prompt(角色 + 阶段规则)— 始终最顶部,定义基本身份;
|
||||
// 2. tool schemas(能力边界)— 稳定参考信息,放在 history 前即可;
|
||||
// 3. history(对话历史、工具调用、修正反馈)— 按时间顺序排列;
|
||||
// 4. pinned blocks(当前计划、当前步骤、粗排结果等最新约束)— 紧贴 user prompt,
|
||||
// 利用近因效应让 LLM 优先关注本轮最相关的约束,而非被历史消息分散注意力;
|
||||
// 5. user prompt(阶段性指令)— 始终在末尾,是本轮回答的核心触发。
|
||||
func buildStageMessages(stageSystemPrompt string, ctx *agentmodel.ConversationContext, runtimeUserPrompt string) []*schema.Message {
|
||||
messages := make([]*schema.Message, 0, 4)
|
||||
|
||||
// 1. 合并 system prompt:基础角色约束 + 阶段规则,始终在最顶部。
|
||||
mergedSystemPrompt := mergeSystemPrompts(ctx, stageSystemPrompt)
|
||||
if mergedSystemPrompt != "" {
|
||||
messages = append(messages, schema.SystemMessage(mergedSystemPrompt))
|
||||
}
|
||||
|
||||
// 2. 工具摘要:稳定参考信息,放在 history 前即可。
|
||||
if toolText := renderToolSchemas(ctx); toolText != "" {
|
||||
messages = append(messages, schema.SystemMessage(toolText))
|
||||
}
|
||||
|
||||
// 3. 对话历史:按时间顺序,包含工具调用结果和修正反馈。
|
||||
if ctx != nil {
|
||||
history := ctx.HistorySnapshot()
|
||||
if len(history) > 0 {
|
||||
// 兼容旧快照:裸 Tool 消息(无 ToolCallID)违反 OpenAI 兼容 API 格式约束,
|
||||
// 会触发 API 拒绝请求导致连接断开。
|
||||
// 这里将裸 Tool 消息降级为 User 消息,保证向后兼容。
|
||||
for i, msg := range history {
|
||||
if msg.Role == schema.Tool && msg.ToolCallID == "" {
|
||||
history[i] = &schema.Message{
|
||||
Role: schema.User,
|
||||
Content: fmt.Sprintf("[工具执行结果]\n%s", msg.Content),
|
||||
}
|
||||
}
|
||||
}
|
||||
messages = append(messages, history...)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 置顶上下文块:当前计划、当前步骤、粗排结果等最新约束。
|
||||
// 放在 history 之后、user prompt 之前,利用 LLM 近因效应提升对最新约束的注意力。
|
||||
if pinnedText := renderPinnedBlocks(ctx); pinnedText != "" {
|
||||
messages = append(messages, schema.SystemMessage(pinnedText))
|
||||
}
|
||||
|
||||
// 5. 阶段性用户提示词:始终在末尾,是本轮回答的核心触发。
|
||||
runtimeUserPrompt = strings.TrimSpace(runtimeUserPrompt)
|
||||
if runtimeUserPrompt != "" {
|
||||
messages = append(messages, schema.UserMessage(runtimeUserPrompt))
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
// renderStateSummary 把当前流程状态渲染成简洁文本。
|
||||
func renderStateSummary(state *agentmodel.CommonState) string {
|
||||
if state == nil {
|
||||
return "当前状态:state 缺失,请先做兜底处理。"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
current, total := state.PlanProgress()
|
||||
|
||||
sb.WriteString(fmt.Sprintf("当前阶段:%s\n", state.Phase))
|
||||
sb.WriteString(fmt.Sprintf("当前轮次:%d/%d\n", state.RoundUsed, state.MaxRounds))
|
||||
if state.HasTerminalOutcome() && state.TerminalOutcome != nil {
|
||||
sb.WriteString(fmt.Sprintf("终止结果:%s\n", state.TerminalOutcome.Status))
|
||||
if strings.TrimSpace(state.TerminalOutcome.Stage) != "" {
|
||||
sb.WriteString(fmt.Sprintf("终止阶段:%s\n", state.TerminalOutcome.Stage))
|
||||
}
|
||||
if strings.TrimSpace(state.TerminalOutcome.Code) != "" {
|
||||
sb.WriteString(fmt.Sprintf("终止代码:%s\n", state.TerminalOutcome.Code))
|
||||
}
|
||||
if strings.TrimSpace(state.TerminalOutcome.UserMessage) != "" {
|
||||
sb.WriteString(fmt.Sprintf("终止说明:%s\n", state.TerminalOutcome.UserMessage))
|
||||
}
|
||||
}
|
||||
|
||||
if !state.HasPlan() {
|
||||
sb.WriteString("当前完整 plan:暂无。\n")
|
||||
} else {
|
||||
sb.WriteString("当前完整 plan:\n")
|
||||
for i, step := range state.PlanSteps {
|
||||
sb.WriteString(fmt.Sprintf("%d. %s\n", i+1, strings.TrimSpace(step.Content)))
|
||||
if strings.TrimSpace(step.DoneWhen) != "" {
|
||||
sb.WriteString(fmt.Sprintf(" 完成判定:%s\n", strings.TrimSpace(step.DoneWhen)))
|
||||
}
|
||||
}
|
||||
|
||||
if step, ok := state.CurrentPlanStep(); ok {
|
||||
sb.WriteString(fmt.Sprintf("当前步骤进度:%d/%d\n", current, total))
|
||||
sb.WriteString("当前步骤内容:\n")
|
||||
sb.WriteString(strings.TrimSpace(step.Content))
|
||||
sb.WriteString("\n")
|
||||
if strings.TrimSpace(step.DoneWhen) != "" {
|
||||
sb.WriteString("当前步骤完成判定:\n")
|
||||
sb.WriteString(strings.TrimSpace(step.DoneWhen))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
} else {
|
||||
sb.WriteString("当前步骤进度:暂时无有效当前步骤。\n")
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染任务类约束元数据(如有),帮助 LLM 了解排程范围和策略,避免追问已有信息。
|
||||
if len(state.TaskClasses) > 0 {
|
||||
sb.WriteString("\n本次排课涉及的任务类约束:\n")
|
||||
for _, tc := range state.TaskClasses {
|
||||
line := fmt.Sprintf("- [ID=%d] %s:策略=%s,总时段预算=%d", tc.ID, tc.Name, tc.Strategy, tc.TotalSlots)
|
||||
if tc.StartDate != "" || tc.EndDate != "" {
|
||||
line += fmt.Sprintf(",日期范围=%s ~ %s", tc.StartDate, tc.EndDate)
|
||||
}
|
||||
if tc.SubjectType != "" || tc.DifficultyLevel != "" || tc.CognitiveIntensity != "" {
|
||||
line += fmt.Sprintf(",语义画像=%s/%s/%s",
|
||||
defaultSemanticValue(tc.SubjectType),
|
||||
defaultSemanticValue(tc.DifficultyLevel),
|
||||
defaultSemanticValue(tc.CognitiveIntensity),
|
||||
)
|
||||
}
|
||||
if tc.AllowFillerCourse {
|
||||
line += ",允许嵌入水课"
|
||||
}
|
||||
if len(tc.ExcludedSlots) > 0 {
|
||||
line += fmt.Sprintf(",排除时段=%v", tc.ExcludedSlots)
|
||||
}
|
||||
if len(tc.ExcludedDaysOfWeek) > 0 {
|
||||
line += fmt.Sprintf(",排除星期=%v", tc.ExcludedDaysOfWeek)
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func defaultSemanticValue(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return "未标注"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// renderPinnedBlocks 把 ConversationContext 中的置顶块渲染成独立的 system 文本。
|
||||
func renderPinnedBlocks(ctx *agentmodel.ConversationContext) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
blocks := ctx.PinnedBlocksSnapshot()
|
||||
if len(blocks) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("以下是后端置顶注入的上下文,请优先遵守:\n")
|
||||
for _, block := range blocks {
|
||||
title := strings.TrimSpace(block.Title)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(block.Key)
|
||||
}
|
||||
if title != "" {
|
||||
sb.WriteString("【")
|
||||
sb.WriteString(title)
|
||||
sb.WriteString("】\n")
|
||||
}
|
||||
sb.WriteString(strings.TrimSpace(block.Content))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// renderToolSchemas 把工具摘要渲染成独立文本块。
|
||||
func renderToolSchemas(ctx *agentmodel.ConversationContext) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
schemas := ctx.ToolSchemasSnapshot()
|
||||
if len(schemas) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("以下是当前可用工具摘要,仅供你在规划时参考能力边界:\n")
|
||||
for _, item := range schemas {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
desc := strings.TrimSpace(item.Desc)
|
||||
schemaText := strings.TrimSpace(item.SchemaText)
|
||||
|
||||
if name != "" {
|
||||
sb.WriteString("- 工具名:")
|
||||
sb.WriteString(name)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if desc != "" {
|
||||
sb.WriteString(" 说明:")
|
||||
sb.WriteString(desc)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if schemaText != "" {
|
||||
sb.WriteString(" 参数摘要:")
|
||||
sb.WriteString(schemaText)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
func mergeSystemPrompts(ctx *agentmodel.ConversationContext, stageSystemPrompt string) string {
|
||||
base := ""
|
||||
if ctx != nil {
|
||||
base = strings.TrimSpace(ctx.SystemPrompt)
|
||||
}
|
||||
stageSystemPrompt = strings.TrimSpace(stageSystemPrompt)
|
||||
|
||||
switch {
|
||||
case base == "" && stageSystemPrompt == "":
|
||||
return ""
|
||||
case base == "":
|
||||
return stageSystemPrompt
|
||||
case stageSystemPrompt == "":
|
||||
return base
|
||||
default:
|
||||
return base + "\n\n" + stageSystemPrompt
|
||||
}
|
||||
}
|
||||
169
backend/services/agent/prompt/chat.go
Normal file
169
backend/services/agent/prompt/chat.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const chatRoutingSystemPrompt = `
|
||||
你是 SmartMate 的聊天路由助手。SmartMate 是时伴(SmartMate)的中文 AI 排程伙伴,面向大学生提供陪伴式日程管理与日常协助;它擅长日程安排、任务管理与学习规划,但不只会做排程。你的回复必须以路由控制码开头,控制码后紧跟用户可见的内容。
|
||||
|
||||
路由规则:
|
||||
- direct_reply:纯闲聊、简单问答、轻量生活建议、打招呼、感谢等不需要工具、也不需要长链路思考的请求。控制码后直接输出完整回复。
|
||||
- quick_task:用户明确想记录/添加一个待办或提醒(如"记一下""提醒我""帮我记"),或查看/筛选任务列表(如"我有什么任务""待办清单""最近急事""今天/明天/本周有什么事要做")。该路由走轻量快捷路径,延迟低、废话少。控制码后不要输出任何内容。
|
||||
- execute:需要用工具处理的日程编排请求(明确查课表/日程块、移动课程、排课等),但不需要先制定计划。控制码后输出简短确认。
|
||||
- deep_answer:复杂问题但不需要工具(如分析建议、知识解释、方案比较、深度讨论等),需要深度思考后回答。控制码后不要输出任何占位过渡语,后端会直接进入第二次正式回答。
|
||||
- plan:用户明确要求先制定计划,或涉及多阶段复杂规划。控制码后输出简短确认。
|
||||
|
||||
quick_task 判别要点:
|
||||
- 用户明确要"记/添加/提醒"一个待办 → quick_task
|
||||
- 用户要查看/筛选/列出任务清单 → quick_task
|
||||
- 用户问"今天/明天/本周有什么待办/任务/事情要做"这类时间窗任务查询 → quick_task
|
||||
- 用户明确在查课表/日程块、排课、移动安排 → execute
|
||||
- 但如果用户同时提了日程排布(如"把明天的课调一下,再记一下周五开会"),混合操作走 execute
|
||||
- 如果信息不足(如"帮我记一下"但没说记什么),走 direct_reply 追问
|
||||
|
||||
任务类设计路由要点:
|
||||
- 普通"创建/修改任务类配置(task class)"默认走 execute(由 execute 负责补字段与写入)。
|
||||
- 仅当用户明确要"补课程学习资料/学习建议/学习路径(需要外部知识)"时,走 plan(后续可使用 web_search)。
|
||||
- 考试时间、DDL、课程具体时间安排、个人可用时段等时间信息,必须向用户本人确认,不能作为 web 搜索补齐目标。
|
||||
|
||||
通用回答约束:
|
||||
- 非日程、非任务类问题,只要不需要工具,也应当正常回答。
|
||||
- 不要因为用户的问题不涉及排程,就说自己“只能处理日程/任务安排”。
|
||||
- 不要把普通问答、生活建议、开放式讨论,硬拐成排程请求。
|
||||
- route=direct_reply 时,控制码后的可见内容应直接回应用户问题,而不是先讲能力边界。
|
||||
- route=deep_answer 时,只输出控制码即可,不要补“让我想想”“这是个好问题”之类的占位话术。
|
||||
|
||||
粗排判断:当用户意图包含"批量安排/排课/把任务类排进日程"等批量调度需求时,可设置 rough_build=true;后端会结合真实请求范围决定是否真正进入粗排。
|
||||
二次粗排约束(强约束):
|
||||
- 若上下文已出现 rough_build_done,且用户未明确要求"重新粗排/从头重排",必须设置 rough_build=false。
|
||||
- "移动/微调/优化/均匀化/调顺序"等请求默认视为 refine,不得再次触发 rough build。
|
||||
粗排后微调判断:
|
||||
- 仅当 rough_build=true 时才判断 refine。
|
||||
- 默认策略:首次粗排完成后应进入微调(refine=true),按中位标准做主动优化。
|
||||
- 仅当用户明确表达"只要初稿/先排进去别优化/先不微调/排完就收口"时,才设 refine=false。
|
||||
顺序授权判断:
|
||||
- reorder 仅在用户明确说明"允许打乱顺序/顺序不重要"时才为 true。
|
||||
- 用户明确要求"保持顺序/不要打乱"时必须为 false。
|
||||
- 若用户未明确提及顺序,一律为 false。
|
||||
深度思考判断:
|
||||
- thinking 仅在 route=execute 时有效。
|
||||
- 当用户请求涉及复杂推理、多条件约束、需要深度分析后才能执行的操作时,设 thinking=true。
|
||||
- 简单查询、单步操作设 thinking=false。
|
||||
|
||||
输出格式(严格两段式):
|
||||
第一段(控制码,用户不可见,后端会截取):
|
||||
<SMARTFLOW_ROUTE nonce="给定nonce" route="direct_reply|execute|deep_answer|plan|quick_task" rough_build="false" refine="false" reorder="false" thinking="false"/>
|
||||
第二段(紧接控制码之后,用户可见):
|
||||
根据路由输出对应内容。
|
||||
|
||||
属性说明(仅 route=execute 时有效,其余路由省略这些属性):
|
||||
- rough_build:是否需要粗排
|
||||
- refine:粗排后是否需要微调
|
||||
- reorder:是否允许打乱顺序
|
||||
- thinking:后续执行阶段是否需要深度思考
|
||||
|
||||
合法示例:
|
||||
|
||||
<SMARTFLOW_ROUTE nonce="给定nonce" route="direct_reply"/>
|
||||
当然可以,我先直接回答你这个问题。
|
||||
|
||||
<SMARTFLOW_ROUTE nonce="给定nonce" route="quick_task"/>
|
||||
|
||||
<SMARTFLOW_ROUTE nonce="给定nonce" route="execute"/>
|
||||
好的,我来帮你看看今天的安排。
|
||||
|
||||
<SMARTFLOW_ROUTE nonce="给定nonce" route="execute" rough_build="true" refine="false" reorder="false" thinking="false"/>
|
||||
好的,我来帮你排课。
|
||||
|
||||
<SMARTFLOW_ROUTE nonce="给定nonce" route="execute" rough_build="true" refine="true" reorder="false" thinking="true"/>
|
||||
好的,我来帮你排课并按你的偏好做微调。
|
||||
|
||||
<SMARTFLOW_ROUTE nonce="给定nonce" route="deep_answer"/>
|
||||
|
||||
<SMARTFLOW_ROUTE nonce="给定nonce" route="plan"/>
|
||||
明白,我来帮你制定一个完整的学习计划。
|
||||
|
||||
禁止输出任何 JSON、markdown 代码块或额外解释。nonce 必须精确使用给定值。
|
||||
`
|
||||
|
||||
// BuildChatRoutingSystemPrompt 返回路由阶段的系统提示词。
|
||||
func BuildChatRoutingSystemPrompt() string {
|
||||
return strings.TrimSpace(chatRoutingSystemPrompt)
|
||||
}
|
||||
|
||||
// BuildChatRoutingMessages 组装路由阶段的 messages。
|
||||
func BuildChatRoutingMessages(ctx *agentmodel.ConversationContext, userInput string, state *agentmodel.CommonState, nonce string) []*schema.Message {
|
||||
return buildUnifiedStageMessages(
|
||||
ctx,
|
||||
StageMessagesConfig{
|
||||
SystemPrompt: BuildChatRoutingSystemPrompt(),
|
||||
Msg1Content: buildChatConversationMessage(ctx),
|
||||
Msg2Content: buildChatRoutingWorkspace(ctx),
|
||||
Msg3Suffix: BuildChatRoutingUserPrompt(userInput, nonce),
|
||||
Msg3Role: schema.User,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// BuildChatRoutingUserPrompt 构造路由阶段的用户提示词。
|
||||
func BuildChatRoutingUserPrompt(userInput string, nonce string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("nonce=%s\n", nonce))
|
||||
sb.WriteString(fmt.Sprintf("当前时间=%s\n", time.Now().In(time.Local).Format("2006-01-02 15:04")))
|
||||
sb.WriteString("\n请基于最近真实对话和本轮输入选择最合适的路由,并严格按系统约定输出控制码。\n")
|
||||
|
||||
trimmedInput := strings.TrimSpace(userInput)
|
||||
if trimmedInput != "" {
|
||||
sb.WriteString("\n用户本轮输入:\n")
|
||||
sb.WriteString(trimmedInput)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// --- 深度回答 prompt ---
|
||||
|
||||
const deepAnswerSystemPrompt = `
|
||||
你是 SmartMate 的深度分析助手。SmartMate 是时伴(SmartMate)的中文 AI 排程伙伴;即使问题与日程、任务无关,只要不需要工具,你也应当认真分析后给出详细、有价值的回答。
|
||||
|
||||
请遵守以下规则:
|
||||
1. 优先回答用户真实问题,不要把普通问答硬拐回排程、任务或计划制定。
|
||||
2. 充分利用上下文中已有的信息(历史对话、记忆、任务类约束、日程数据等),但不要无关硬套。
|
||||
3. 如果缺少关键信息,在回答中说明需要哪些额外信息。
|
||||
4. 直接输出你的回答,不要输出 JSON。
|
||||
`
|
||||
|
||||
// BuildDeepAnswerSystemPrompt 返回深度回答阶段的系统提示词。
|
||||
func BuildDeepAnswerSystemPrompt() string {
|
||||
return strings.TrimSpace(deepAnswerSystemPrompt)
|
||||
}
|
||||
|
||||
// BuildDeepAnswerMessages 组装深度回答阶段的 messages。
|
||||
func BuildDeepAnswerMessages(state *agentmodel.CommonState, ctx *agentmodel.ConversationContext, userInput string) []*schema.Message {
|
||||
return buildUnifiedStageMessages(
|
||||
ctx,
|
||||
StageMessagesConfig{
|
||||
SystemPrompt: BuildDeepAnswerSystemPrompt(),
|
||||
Msg1Content: buildChatConversationMessage(ctx),
|
||||
Msg2Content: buildDeepAnswerWorkspace(),
|
||||
Msg3Suffix: buildDeepAnswerUserPrompt(userInput),
|
||||
Msg3Role: schema.User,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func buildDeepAnswerUserPrompt(userInput string) string {
|
||||
trimmedInput := strings.TrimSpace(userInput)
|
||||
if trimmedInput != "" {
|
||||
return trimmedInput
|
||||
}
|
||||
return "请直接回答用户刚才的问题。"
|
||||
}
|
||||
33
backend/services/agent/prompt/chat_context.go
Normal file
33
backend/services/agent/prompt/chat_context.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
)
|
||||
|
||||
// buildChatConversationMessage 生成 chat / deep_answer 共用的真实对话视图。
|
||||
func buildChatConversationMessage(ctx *agentmodel.ConversationContext) string {
|
||||
return buildConversationHistoryMessage(ctx, "真实对话记录")
|
||||
}
|
||||
|
||||
// buildChatRoutingWorkspace 渲染 chat 路由节点的轻量补充区。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. chat 只保留与路由判断直接相关的最小流程标记;
|
||||
// 2. rough_build_done 仍需显式暴露,否则路由层会丢掉“不要重复粗排”的关键信号;
|
||||
// 3. 不再展示轮次、阶段锚点、ReAct 摘要等 execute 专属信息。
|
||||
func buildChatRoutingWorkspace(ctx *agentmodel.ConversationContext) string {
|
||||
lines := []string{"路由补充:"}
|
||||
if hasExecuteRoughBuildDone(ctx) {
|
||||
lines = append(lines, "- 已存在 rough_build_done;除非用户明确要求重新粗排,否则不要再次触发 rough_build。")
|
||||
} else {
|
||||
lines = append(lines, "- 暂无额外流程标记。")
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// buildDeepAnswerWorkspace 渲染 deep_answer 节点的轻量工作区。
|
||||
func buildDeepAnswerWorkspace() string {
|
||||
return "回答补充:请直接延续最近对话,聚焦回答用户本轮问题。"
|
||||
}
|
||||
62
backend/services/agent/prompt/compact_msg1.go
Normal file
62
backend/services/agent/prompt/compact_msg1.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const compactMsg1SystemPrompt = `你是一个对话压缩助手。你的任务是将以下多轮对话历史压缩为一段简洁的结构化摘要。
|
||||
|
||||
要求:
|
||||
1. 保留用户的核心诉求和意图(原文关键词不要丢失)
|
||||
2. 保留所有已确认的约束条件和规则
|
||||
3. 保留关键操作决策和结果(比如排程相关的调整结果)
|
||||
4. 保留用户偏好的重要信息
|
||||
5. 去除冗余和重复信息
|
||||
6. 按要点列出,每条一行
|
||||
|
||||
直接输出压缩后的摘要,不要输出其他内容。`
|
||||
|
||||
// CompactMsg1 将 msg1(历史对话)压缩为摘要。
|
||||
// existingSummary 不为空时表示已有旧摘要,需要合并压缩。
|
||||
func CompactMsg1(
|
||||
ctx context.Context,
|
||||
client *llmservice.Client,
|
||||
historyText string,
|
||||
existingSummary string,
|
||||
) (string, error) {
|
||||
var userContent string
|
||||
if existingSummary != "" {
|
||||
userContent = fmt.Sprintf(`已有压缩摘要:
|
||||
%s
|
||||
|
||||
新增的对话记录:
|
||||
%s
|
||||
|
||||
请将以上两部分合并为一份更紧凑的摘要。`, existingSummary, historyText)
|
||||
} else {
|
||||
userContent = fmt.Sprintf(`对话历史:
|
||||
%s
|
||||
|
||||
请压缩以上对话历史。`, historyText)
|
||||
}
|
||||
|
||||
messages := []*schema.Message{
|
||||
schema.SystemMessage(compactMsg1SystemPrompt),
|
||||
schema.UserMessage(userContent),
|
||||
}
|
||||
|
||||
result, err := client.GenerateText(ctx, messages, llmservice.GenerateOptions{
|
||||
MaxTokens: 4000,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("compact msg1 LLM call failed: %w", err)
|
||||
}
|
||||
if result == nil || result.Text == "" {
|
||||
return "", fmt.Errorf("compact msg1 LLM returned empty result")
|
||||
}
|
||||
return result.Text, nil
|
||||
}
|
||||
49
backend/services/agent/prompt/compact_msg2.go
Normal file
49
backend/services/agent/prompt/compact_msg2.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const compactMsg2SystemPrompt = `你是一个执行记录压缩助手。你的任务是将以下 ReAct 执行循环记录压缩为简洁摘要。
|
||||
|
||||
要求:
|
||||
1. 保留每个工具调用的关键返回值(尤其是包含排程数据的JSON)
|
||||
2. 保留执行路径(哪些操作成功了,哪些失败了)
|
||||
3. 保留当前执行进度(正在做什么,下一步要做什么)
|
||||
4. 去除重复的工具调用结果
|
||||
5. 按时间顺序组织,每条一行
|
||||
|
||||
直接输出压缩后的摘要,不要输出其他内容。`
|
||||
|
||||
// CompactMsg2 将 msg2(ReAct Loop 记录)的早期部分压缩为摘要。
|
||||
// recentText 是保留的近期记录原文,不参与压缩。
|
||||
func CompactMsg2(
|
||||
ctx context.Context,
|
||||
client *llmservice.Client,
|
||||
earlyLoopText string,
|
||||
) (string, error) {
|
||||
userContent := fmt.Sprintf(`早期的 ReAct 执行记录:
|
||||
%s
|
||||
|
||||
请压缩以上执行记录,保留关键信息。`, earlyLoopText)
|
||||
|
||||
messages := []*schema.Message{
|
||||
schema.SystemMessage(compactMsg2SystemPrompt),
|
||||
schema.UserMessage(userContent),
|
||||
}
|
||||
|
||||
result, err := client.GenerateText(ctx, messages, llmservice.GenerateOptions{
|
||||
MaxTokens: 4000,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("compact msg2 LLM call failed: %w", err)
|
||||
}
|
||||
if result == nil || result.Text == "" {
|
||||
return "", fmt.Errorf("compact msg2 LLM returned empty result")
|
||||
}
|
||||
return result.Text, nil
|
||||
}
|
||||
37
backend/services/agent/prompt/conversation_view.go
Normal file
37
backend/services/agent/prompt/conversation_view.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
)
|
||||
|
||||
// buildConversationHistoryMessage 将“真实对话流”渲染成节点可直接复用的 msg1。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责把 user + assistant speak 组织成稳定文本;
|
||||
// 2. 不拼接 tool_call / tool observation,这些不属于“真实对话”;
|
||||
// 3. 不做长度裁剪,长度预算交给统一压缩层处理。
|
||||
func buildConversationHistoryMessage(ctx *agentmodel.ConversationContext, title string) string {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
title = "真实对话记录"
|
||||
}
|
||||
|
||||
lines := []string{title + ":"}
|
||||
if ctx == nil {
|
||||
lines = append(lines, "暂无。")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
turns := CollectConversationTurns(ctx.HistorySnapshot())
|
||||
if len(turns) == 0 {
|
||||
lines = append(lines, "暂无。")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
for _, turn := range turns {
|
||||
lines = append(lines, turn.Role+": \""+turn.Content+"\"")
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
78
backend/services/agent/prompt/deliver.go
Normal file
78
backend/services/agent/prompt/deliver.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const deliverSystemPrompt = `
|
||||
你是 SmartMate 的交付器。你的职责是基于原始计划和执行历史,生成一份简洁、诚实的任务完成总结。
|
||||
请遵守以下规则:
|
||||
1. 只基于已有历史和计划状态生成总结,不要编造未执行的操作。
|
||||
2. 如果所有步骤都已完成,请自然概括每一步的主要成果。
|
||||
3. 如果流程因轮次耗尽或主动终止而提前结束,请如实说明当前进度与未完成部分。
|
||||
4. 使用自然、友好的语气,不要机械罗列工具过程。
|
||||
5. 如果用户后续还需要继续操作,可以给出一句简短建议。
|
||||
6. 只输出总结文本,不要输出 JSON,也不要输出 markdown 标题。
|
||||
你会看到:
|
||||
- 原始计划步骤及完成进度
|
||||
- 最近真实对话
|
||||
- 当前流程的收口状态`
|
||||
|
||||
// BuildDeliverSystemPrompt 返回交付阶段系统提示词。
|
||||
func BuildDeliverSystemPrompt() string {
|
||||
return strings.TrimSpace(deliverSystemPrompt)
|
||||
}
|
||||
|
||||
// BuildDeliverMessages 组装交付阶段 messages。
|
||||
func BuildDeliverMessages(state *agentmodel.CommonState, ctx *agentmodel.ConversationContext) []*schema.Message {
|
||||
roughBuildPrefix := buildDeliverRoughBuildPrefix(ctx, state)
|
||||
return buildUnifiedStageMessages(
|
||||
ctx,
|
||||
StageMessagesConfig{
|
||||
SystemPrompt: BuildDeliverSystemPrompt(),
|
||||
Msg1Content: buildDeliverConversationMessage(ctx),
|
||||
Msg2Content: buildDeliverWorkspace(state, ctx),
|
||||
Msg3Prefix: roughBuildPrefix,
|
||||
Msg3Suffix: BuildDeliverUserPrompt(state, ctx),
|
||||
Msg3Role: schema.User,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// BuildDeliverUserPrompt 构造交付阶段的用户提示词。
|
||||
func BuildDeliverUserPrompt(state *agentmodel.CommonState, ctx *agentmodel.ConversationContext) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("请基于最近对话和交付工作区,生成一段自然、诚实的完成总结。\n")
|
||||
|
||||
if state == nil || !state.HasPlan() {
|
||||
if hasExecuteRoughBuildDone(ctx) {
|
||||
sb.WriteString("当前没有正式计划,但本轮已经完成粗排,请结合粗排补充和任务类详情总结粗排结果,不要把它说成正式完结。\n")
|
||||
} else {
|
||||
sb.WriteString("当前没有正式计划,请只概括本次互动,不要编造成果。\n")
|
||||
}
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
completed := countCompletedPlanSteps(state)
|
||||
total := len(state.PlanSteps)
|
||||
|
||||
if state.IsExhaustedTerminal() {
|
||||
sb.WriteString(fmt.Sprintf("注意:任务因轮次耗尽提前结束,当前已完成 %d/%d 步。\n", completed, total))
|
||||
sb.WriteString("请如实说明已完成与未完成的部分,并给出一句继续建议。\n")
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
if state.IsAborted() {
|
||||
sb.WriteString(fmt.Sprintf("注意:流程已被主动终止,当前已完成 %d/%d 步。\n", completed, total))
|
||||
sb.WriteString("请如实说明停在何处,以及用户若想继续应如何衔接。\n")
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
sb.WriteString("若计划已正常完成,请概括整体成果;若仍有未完成步骤,也必须如实说明。\n")
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
145
backend/services/agent/prompt/deliver_context.go
Normal file
145
backend/services/agent/prompt/deliver_context.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
)
|
||||
|
||||
// buildDeliverConversationMessage 生成 deliver 节点看到的轻量历史提示。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里不再承载完整历史,也不再把旧轮次对话重新灌回 deliver;
|
||||
// 2. 真正可供收口的本轮 execute 窗口放到 msg2,由工作区统一呈现;
|
||||
// 3. 这里只给模型一个明确提示:历史已经折叠,请不要主动回顾旧轮次。
|
||||
func buildDeliverConversationMessage(ctx *agentmodel.ConversationContext) string {
|
||||
return "历史视图:已折叠到交付工作区的本轮 execute 窗口,请仅依据 msg2 收口,不要回顾旧轮次。"
|
||||
}
|
||||
|
||||
// buildDeliverRoughBuildPrefix 构造 deliver 在“粗排已完成”场景下的专属前缀。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只负责把粗排相关的任务类信息补进 msg3 前缀,不改写交付总结本身;
|
||||
// 2. 只有在上下文里明确存在 rough_build_done 时才注入,避免普通交付场景被额外信息污染;
|
||||
// 3. 这段前缀用于补齐第一次粗排没有正式计划时的任务类详情,优先让 deliver 看到 task_class_ids 和任务类约束。
|
||||
func buildDeliverRoughBuildPrefix(ctx *agentmodel.ConversationContext, state *agentmodel.CommonState) string {
|
||||
if !hasExecuteRoughBuildDone(ctx) {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := []string{
|
||||
"粗排补充信息:",
|
||||
"- 本轮已经完成粗排,相关任务类已进入 suggested/existing,不要把它们说成正式计划。",
|
||||
}
|
||||
|
||||
if taskClassIDs := renderPlanTaskClassIDs(state); taskClassIDs != "" {
|
||||
lines = append(lines, "- "+taskClassIDs)
|
||||
}
|
||||
if taskClassMeta := renderPlanTaskClassMeta(state); taskClassMeta != "" {
|
||||
lines = append(lines, "任务类详情:")
|
||||
lines = append(lines, taskClassMeta)
|
||||
}
|
||||
|
||||
if state == nil || !state.HasPlan() {
|
||||
lines = append(lines, "- 当前没有正式计划,请把这批任务类的粗排结果作为总结重点。")
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// buildDeliverWorkspace 渲染 deliver 节点自己的结果态工作区。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 先保留 deliver 原本依赖的结果态信息:terminal outcome、计划进度、步骤简表;
|
||||
// 2. 再把基于 execute_loop_closed 切出来的“本轮 execute 窗口”拼到 msg2,作为唯一的本轮事实视图;
|
||||
// 3. 没有正式计划时也保留 execute 窗口,保证 deliver 仍能基于当前轮活跃上下文诚实收口。
|
||||
func buildDeliverWorkspace(state *agentmodel.CommonState, ctx *agentmodel.ConversationContext) string {
|
||||
lines := []string{"交付工作区:"}
|
||||
if state == nil {
|
||||
lines = append(lines, "- 当前缺少流程状态,请仅基于可见结果态与本轮 execute 窗口诚实收口。")
|
||||
lines = append(lines, "", buildDeliverExecuteWindow(ctx))
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
lines = append(lines, renderDeliverTerminalSummary(state))
|
||||
if !state.HasPlan() {
|
||||
lines = append(lines, "- 当前没有正式计划,请只概括本次互动。")
|
||||
lines = append(lines, "", buildDeliverExecuteWindow(ctx))
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
total := len(state.PlanSteps)
|
||||
completed := countCompletedPlanSteps(state)
|
||||
lines = append(lines, fmt.Sprintf("- 计划进度:已完成 %d/%d 步。", completed, total))
|
||||
lines = append(lines, "计划步骤:")
|
||||
lines = append(lines, renderDeliverStepOutline(state, completed))
|
||||
lines = append(lines, "", buildDeliverExecuteWindow(ctx))
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// renderDeliverTerminalSummary 返回 deliver 节点需要知道的收口状态。
|
||||
func renderDeliverTerminalSummary(state *agentmodel.CommonState) string {
|
||||
if state == nil || !state.HasTerminalOutcome() || state.TerminalOutcome == nil {
|
||||
return "- 当前没有正式终止结果,请按最近对话和计划进度自然总结。"
|
||||
}
|
||||
|
||||
outcome := state.TerminalOutcome
|
||||
line := fmt.Sprintf("- 收口状态:%s", outcome.Status)
|
||||
if stage := strings.TrimSpace(outcome.Stage); stage != "" {
|
||||
line += fmt.Sprintf(";阶段:%s", stage)
|
||||
}
|
||||
if msg := strings.TrimSpace(outcome.UserMessage); msg != "" {
|
||||
line += fmt.Sprintf(";用户提示:%s", msg)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// renderDeliverStepOutline 生成 deliver 节点使用的步骤简表。
|
||||
func renderDeliverStepOutline(state *agentmodel.CommonState, completed int) string {
|
||||
if state == nil || len(state.PlanSteps) == 0 {
|
||||
return "- 暂无。"
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(state.PlanSteps))
|
||||
for i, step := range state.PlanSteps {
|
||||
status := "未完成"
|
||||
if i < completed {
|
||||
status = "已完成"
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(step.Content)
|
||||
if content == "" {
|
||||
content = "(步骤正文为空)"
|
||||
}
|
||||
line := fmt.Sprintf("%d. [%s] %s", i+1, status, content)
|
||||
if doneWhen := strings.TrimSpace(step.DoneWhen); doneWhen != "" {
|
||||
line += fmt.Sprintf(" | 完成判定:%s", doneWhen)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// countCompletedPlanSteps 统计当前已经完成的计划步骤数。
|
||||
func countCompletedPlanSteps(state *agentmodel.CommonState) int {
|
||||
if state == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
total := len(state.PlanSteps)
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
if state.CurrentStep <= 0 {
|
||||
if state.IsCompleted() {
|
||||
return total
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if state.CurrentStep >= total {
|
||||
return total
|
||||
}
|
||||
return state.CurrentStep
|
||||
}
|
||||
103
backend/services/agent/prompt/deliver_window.go
Normal file
103
backend/services/agent/prompt/deliver_window.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const deliverHistoryKindExecuteLoopClosed = "execute_loop_closed"
|
||||
|
||||
// sliceHistoryAfterLastExecuteLoopClosed 基于最后一个 execute_loop_closed 标记切出当前活跃窗口。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先读取完整 history 快照,避免直接在 ConversationContext 原地切片,减少后续调用方误改底层数组的风险;
|
||||
// 2. 从后往前找最后一个 execute_loop_closed,确保拿到的是“最近一次已正常收口”的边界;
|
||||
// 3. 命中边界后只返回边界之后的消息,这样 deliver 看到的就是当前活跃轮次;
|
||||
// 4. 若完全没有边界,说明会话尚未形成稳定闭环,此时退回全量 history,避免误丢当前活跃上下文。
|
||||
func sliceHistoryAfterLastExecuteLoopClosed(ctx *agentmodel.ConversationContext) []*schema.Message {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
history := ctx.HistorySnapshot()
|
||||
if len(history) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cut := -1
|
||||
for i := len(history) - 1; i >= 0; i-- {
|
||||
if isDeliverExecuteLoopClosedMarker(history[i]) {
|
||||
cut = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if cut < 0 {
|
||||
return history
|
||||
}
|
||||
if cut+1 >= len(history) {
|
||||
return nil
|
||||
}
|
||||
return history[cut+1:]
|
||||
}
|
||||
|
||||
// isDeliverExecuteLoopClosedMarker 判断一条历史消息是否为 execute loop 正常收口边界。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只识别 prompt 层真正关心的 execute_loop_closed 标记;
|
||||
// 2. 不负责推断其他中断/恢复语义,避免把 confirm/ask_user 等同一轮过程误判成新边界;
|
||||
// 3. 若消息结构不完整,则统一按“非边界”处理,保证切窗策略保守可回退。
|
||||
func isDeliverExecuteLoopClosedMarker(msg *schema.Message) bool {
|
||||
if msg == nil || msg.Extra == nil {
|
||||
return false
|
||||
}
|
||||
kind, ok := msg.Extra[executeHistoryKindKey].(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(kind) == deliverHistoryKindExecuteLoopClosed
|
||||
}
|
||||
|
||||
// buildDeliverExecuteWindow 基于当前活跃 history 窗口渲染 deliver 节点要看的执行事实视图。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先按 execute_loop_closed 切掉旧轮次,只保留当前仍活跃的执行窗口;
|
||||
// 2. 再分别抽取“本轮真实对话流”和“本轮 ReAct 工具事实链”,避免 deliver 回看旧 deliver 总结;
|
||||
// 3. 若本轮还没有工具调用,也要明确告诉模型“当前无工具事实”,避免它擅自脑补;
|
||||
// 4. 整段文本只服务 deliver.msg2,不改变四段式骨架,也不回写任何状态。
|
||||
func buildDeliverExecuteWindow(ctx *agentmodel.ConversationContext) string {
|
||||
lines := []string{"本轮 execute 窗口:"}
|
||||
|
||||
historyWindow := sliceHistoryAfterLastExecuteLoopClosed(ctx)
|
||||
if len(historyWindow) == 0 {
|
||||
lines = append(lines, "- 当前没有可用的本轮执行窗口,请仅依据结果态工作区诚实收口。")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
turns := collectExecuteConversationTurns(historyWindow)
|
||||
if len(turns) == 0 {
|
||||
lines = append(lines, "- 本轮对话流:暂无。")
|
||||
} else {
|
||||
lines = append(lines, "本轮对话流:")
|
||||
for _, turn := range turns {
|
||||
lines = append(lines, fmt.Sprintf("- %s: %q", turn.Role, turn.Content))
|
||||
}
|
||||
}
|
||||
|
||||
loops := collectExecuteLoopRecords(historyWindow)
|
||||
if len(loops) == 0 {
|
||||
lines = append(lines, "- 本轮 ReAct 记录:暂无工具调用。")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
lines = append(lines, "本轮 ReAct 记录:")
|
||||
for i, loop := range loops {
|
||||
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")
|
||||
}
|
||||
137
backend/services/agent/prompt/execute.go
Normal file
137
backend/services/agent/prompt/execute.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// BuildExecuteSystemPrompt 返回执行阶段系统提示词(有 plan 模式)。
|
||||
func BuildExecuteSystemPrompt() string {
|
||||
return buildExecutePromptWithFormatGuard(executeSystemPromptBaseWithPlan)
|
||||
}
|
||||
|
||||
// BuildExecuteReActSystemPrompt 返回执行阶段系统提示词(自由执行模式)。
|
||||
func BuildExecuteReActSystemPrompt() string {
|
||||
return buildExecutePromptWithFormatGuard(executeSystemPromptBaseReAct)
|
||||
}
|
||||
|
||||
// BuildExecuteMessages 组装执行阶段消息。
|
||||
func BuildExecuteMessages(state *agentmodel.CommonState, ctx *agentmodel.ConversationContext) []*schema.Message {
|
||||
if state != nil && state.HasPlan() {
|
||||
return buildExecuteStageMessages(
|
||||
BuildExecuteSystemPrompt(),
|
||||
state,
|
||||
ctx,
|
||||
buildExecuteStrictJSONUserPromptWithPlan(state),
|
||||
)
|
||||
}
|
||||
|
||||
return buildExecuteStageMessages(
|
||||
BuildExecuteReActSystemPrompt(),
|
||||
state,
|
||||
ctx,
|
||||
buildExecuteStrictJSONUserPrompt(),
|
||||
)
|
||||
}
|
||||
|
||||
// buildExecuteStrictJSONUserPromptWithPlan 在通用 JSON 约束上补充"当前计划步骤"强约束。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把"当前是第几步、当前步骤内容、done_when 判定"明确写进用户指令;
|
||||
// 2. 不负责替代系统提示词中的工具规则和安全边界;
|
||||
// 3. 当 state 无法提供有效当前步骤时,仅追加兜底提示,不在此处推进流程状态。
|
||||
func buildExecuteStrictJSONUserPromptWithPlan(state *agentmodel.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 总结完成证据。
|
||||
- goal_check 字段类型必须为 string,不要输出对象或数组。
|
||||
- 若未完成但缺少关键信息:输出 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 给出证据。
|
||||
- goal_check 字段类型固定为 string(示例:"已满足 done_when:...;证据:..."),禁止输出 {"done_when":"...","evidence":"..."}。
|
||||
- 禁止跳步:不要提前执行后续步骤。`,
|
||||
base, current, total, stepContent, doneWhen))
|
||||
}
|
||||
|
||||
// buildExecutePromptWithFormatGuard 统一补一层更硬的 JSON 输出约束。
|
||||
func buildExecutePromptWithFormatGuard(base string) string {
|
||||
base = strings.TrimSpace(base)
|
||||
guard := strings.TrimSpace(`
|
||||
输出协议硬约束:
|
||||
1. 只输出当前 action 真正需要的字段;不要输出空字符串、空对象、空数组或 null 占位。
|
||||
2. tool_call 只能是 {"name":"工具名","arguments":{...}};不能写 parameters,也不能一次输出多个 tool_call。
|
||||
3. action=ask_user / confirm 时,标签后必须有自然语言正文;action=continue 可为空,但只允许配合读工具或纯思考,不能携带任何写工具。
|
||||
4. action=done 时不要携带 tool_call;action=next_plan / done 时,goal_check 必须是字符串。
|
||||
5. 只有 action=abort 时才允许输出 abort 字段。
|
||||
6. <SMARTFLOW_DECISION> 标签内只放 JSON,不要放自然语言。
|
||||
7. 不要在 <SMARTFLOW_DECISION> 标签前输出任何前言、寒暄、解释或铺垫;给用户看的正文只能放在 </SMARTFLOW_DECISION> 之后。
|
||||
8. 任何动作都不得擅自超出用户当前明确意图;用户没让你做的下一步,不要自作主张推进。`)
|
||||
if base == "" {
|
||||
return guard
|
||||
}
|
||||
return base + "\n\n" + guard
|
||||
}
|
||||
|
||||
// buildExecuteStrictJSONUserPrompt 统一构造 execute 阶段面向模型的最终用户指令。
|
||||
func buildExecuteStrictJSONUserPrompt() string {
|
||||
return strings.TrimSpace(`
|
||||
请继续当前任务的执行阶段,严格按 SMARTFLOW_DECISION 标签格式输出。
|
||||
输出格式:先输出 <SMARTFLOW_DECISION>{JSON 决策}</SMARTFLOW_DECISION>,然后换行输出给用户看的正文。
|
||||
|
||||
执行提醒:
|
||||
- JSON 中不要包含 speak 字段;给用户看的话放在 </SMARTFLOW_DECISION> 标签之后
|
||||
- 不要在 <SMARTFLOW_DECISION> 标签之前输出任何文字;哪怕只有一句“我先看下”也不行
|
||||
- 任何写工具都一律走 action=confirm,包括 upsert_task_class 与日程写工具(place/move/swap/batch_move/unplace);哪怕只是“按 validation.issues 重试一次”,也不能输出 continue + 写工具
|
||||
- 若当前处于粗排后主动优化专用模式,先调 analyze_health,再直接从 decision.candidates 里选一个合法候选去执行;不要自行发明新的全窗搜索步骤
|
||||
- 若读工具结果与已知事实明显冲突,先修正参数并重查一次,再决定是否 ask_user
|
||||
- 不要连续两轮调用“同一读工具 + 等价 arguments”;上一轮已成功返回时,下一轮必须换工具、进入 confirm,或明确说明阻塞
|
||||
- 若上下文已明确“当前未收到微调偏好,本轮先收口”,请直接输出 action=done
|
||||
- web_search 仅用于通用学习资料补充,不可用于考试时间、DDL、个人时段等时间字段填充
|
||||
- 任何写工具在真正输出 action=confirm 前,都必须先做一次“写前检查”:确认参数已齐全、格式合法、业务前提已满足;若尚未通过检查,就先补齐/归一/生成/ask_user,不要把 validation 失败当成正常探索路径
|
||||
- upsert_task_class 若返回 validation.ok=false,必须先按 validation.issues 补齐,再重试;禁止直接 done
|
||||
- 对 upsert_task_class,写前至少检查:mode=auto 时日期边界是否已满足;subject_type / difficulty_level / cognitive_intensity 是否齐;difficulty_level 是否已映射到合法枚举;items 是否非空且顺序内容已生成;config 中已知约束字段是否已落到合法格式
|
||||
- 若像 items 这种内容本就由当前轮模型负责生成,就应先把内容生成齐、顺序排好,再写入;不要先写一个 items 为空的 taskclass 去让 validation 提醒你补内容
|
||||
- 处理 validation.issues 时先分类:若是用户关键信息确实缺失,才 action=ask_user;若是 schema 字段名、字段位置、内部索引、枚举值、日期格式、工具语义映射等内部表示问题,应静默改参后直接重试,不要把底层表示教学抛给用户
|
||||
- 像 config.excluded_slots 的半天块索引映射,默认属于内部表示修正:你应自己把“第1-2节 / 第11-12节”换算成合法块索引,不要为此 ask_user,不要长篇解释底层表示
|
||||
- 当前时间锚点只用于解析用户已经明确说出的相对时间(如“今天开始”“两周内”“下周一前”),不能反过来把“现在是今天”当成用户已经同意从今天开始,更不能据此默认生成 start_date / end_date
|
||||
- 像 auto 模式缺 start_date/end_date 这类问题,先检查当前对话、历史、记忆、已知工具结果里是否已经出现可用日期;若已出现就静默补齐并重试,只有在上下文里确实没有时再 ask_user
|
||||
- 若当前是首次创建/修正 taskclass,且上下文里并没有用户明确给出的开始日期、结束日期、日期范围、完成期限、或可直接换算出的相对时间承诺,就不要擅自写 start_date / end_date;此时若工具闭环确实要求这些字段,必须 ask_user
|
||||
- 对 taskclass 来说,以下属于必须 ask_user 的关键信息:start_date、end_date、明确的日期范围、明确的开始时间承诺、明确的完成期限;这些会决定任务类的真实时间边界,不能由模型自行拍板
|
||||
- subject_type / difficulty_level / cognitive_intensity 是任务类语义画像必填;优先静默推断,只有确实无法判断时再 ask_user
|
||||
- 学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,这些默认都只是 taskclass 语义;不要因为信息完整就自动切进 schedule
|
||||
- 只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才允许 context_tools_add domain="schedule"、触发 rough_build,或继续 schedule 链路
|
||||
- 例:“我要复习离散数学,基础较差,大概学 8 节课,不要早上第 1-2 节和晚上第 11-12 节,周末也不想学,每节课内容你自己来”——应先生成或更新 taskclass,而不是主动排进日程
|
||||
- 仅 upsert_task_class 成功不代表已开始排程;若未触发 rough_build 且未调用任何日程修改工具,禁止承诺“接下来会自动排程”
|
||||
- 当前轮目标若是创建/修正 taskclass,就优先把 taskclass 静默闭环;除非真缺用户关键信息,否则不要把主要篇幅花在解释工具内部约束上
|
||||
`)
|
||||
}
|
||||
788
backend/services/agent/prompt/execute_context.go
Normal file
788
backend/services/agent/prompt/execute_context.go
Normal file
@@ -0,0 +1,788 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
// executeHistoryKindKey 用于在 history 里区分普通用户消息与后端注入的纠错提示。
|
||||
// 这里负责“识别并过滤”,不负责写入该标记。
|
||||
executeHistoryKindKey = "newagent_history_kind"
|
||||
executeHistoryKindCorrectionUser = "llm_correction_prompt"
|
||||
)
|
||||
|
||||
type executeToolSchemaDoc struct {
|
||||
Name string `json:"name"`
|
||||
Parameters map[string]any `json:"parameters"`
|
||||
}
|
||||
|
||||
type executeLoopRecord struct {
|
||||
Thought string
|
||||
ToolName string
|
||||
ToolArgs string
|
||||
Observation string
|
||||
}
|
||||
|
||||
type conversationTurn struct {
|
||||
Role string
|
||||
Content string
|
||||
}
|
||||
|
||||
type executeLatestToolRecord struct {
|
||||
ToolName string
|
||||
Observation string
|
||||
}
|
||||
|
||||
// buildExecuteStageMessages 组装 execute 阶段的四段式消息。
|
||||
//
|
||||
// 1. msg0:系统提示 + 动态规则包 + 工具简表。
|
||||
// 2. msg1:真实对话流,只保留 user 和 assistant speak。
|
||||
// 3. msg2:当前 ReAct tool loop 记录。
|
||||
// 4. msg3:执行状态、阶段约束、记忆和本轮指令。
|
||||
func buildExecuteStageMessages(
|
||||
stageSystemPrompt string,
|
||||
state *agentmodel.CommonState,
|
||||
ctx *agentmodel.ConversationContext,
|
||||
runtimeUserPrompt string,
|
||||
) []*schema.Message {
|
||||
msg0 := buildExecuteMessage0(stageSystemPrompt, state, ctx)
|
||||
msg1 := buildExecuteMessage1V3(ctx)
|
||||
msg2 := buildExecuteMessage2V3(ctx)
|
||||
msg3 := buildExecuteMessage3(state, ctx, runtimeUserPrompt)
|
||||
|
||||
return []*schema.Message{
|
||||
schema.SystemMessage(msg0),
|
||||
{Role: schema.Assistant, Content: msg1},
|
||||
{Role: schema.Assistant, Content: msg2},
|
||||
schema.SystemMessage(msg3),
|
||||
}
|
||||
}
|
||||
|
||||
// buildExecuteMessage0 生成 execute 阶段的固定规则消息。
|
||||
//
|
||||
// 1. 先拼基础 system prompt,保证身份和输出协议稳定。
|
||||
// 2. 再按当前 domain / packs 注入动态规则包,让模型先读到边界。
|
||||
// 3. 最后再附工具简表,避免模型只看到工具不看到纪律。
|
||||
func buildExecuteMessage0(stageSystemPrompt string, state *agentmodel.CommonState, ctx *agentmodel.ConversationContext) string {
|
||||
base := strings.TrimSpace(mergeSystemPrompts(ctx, stageSystemPrompt))
|
||||
if base == "" {
|
||||
base = "你是 SmartMate 执行器,请继续当前执行阶段。"
|
||||
}
|
||||
|
||||
rulePackSection, _ := renderExecuteRulePackSection(state, ctx)
|
||||
if rulePackSection != "" {
|
||||
base += "\n\n" + rulePackSection
|
||||
}
|
||||
|
||||
toolCatalog := renderExecuteToolCatalogCompact(ctx, state)
|
||||
if toolCatalog != "" {
|
||||
base += "\n\n" + toolCatalog
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// buildExecuteMessage1V3 只渲染真实对话流,不混入 tool observation。
|
||||
func buildExecuteMessage1V3(ctx *agentmodel.ConversationContext) string {
|
||||
lines := []string{"历史上下文:"}
|
||||
if ctx == nil {
|
||||
lines = append(lines,
|
||||
"- 对话历史:暂无。",
|
||||
"- 阶段锚点:按当前工具事实推进执行。",
|
||||
)
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
turns := collectExecuteConversationTurns(ctx.HistorySnapshot())
|
||||
if len(turns) == 0 {
|
||||
lines = append(lines, "- 对话历史:暂无。")
|
||||
} else {
|
||||
turnLines := make([]string, 0, len(turns)+1)
|
||||
turnLines = append(turnLines, "对话历史:")
|
||||
for _, turn := range turns {
|
||||
turnLines = append(turnLines, turn.Role+": \""+turn.Content+"\"")
|
||||
}
|
||||
lines = append(lines, strings.Join(turnLines, "\n"))
|
||||
}
|
||||
|
||||
if hasExecuteRoughBuildDone(ctx) {
|
||||
lines = append(lines, "- 阶段锚点:粗排已完成,本轮仅做微调,不重新 place。")
|
||||
} else {
|
||||
lines = append(lines, "- 阶段锚点:按当前工具事实推进,不做无依据操作。")
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// buildExecuteMessage2V3 只承载当轮 ReAct loop。
|
||||
//
|
||||
// 1. 每条记录固定展示 thought / tool_call / observation,方便模型做局部闭环。
|
||||
// 2. 如果当前还没有任何 tool loop,明确给“新一轮”占位,避免模型误判缺上下文。
|
||||
func buildExecuteMessage2V3(ctx *agentmodel.ConversationContext) string {
|
||||
lines := []string{"当轮 ReAct Loop 记录:"}
|
||||
if ctx == nil {
|
||||
lines = append(lines, "- 暂无可用 ReAct 记录。")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
loops := collectExecuteLoopRecords(ctx.HistorySnapshot())
|
||||
if len(loops) == 0 {
|
||||
lines = append(lines, "- 已清空(新一轮 loop 准备中)。")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
for i, loop := range loops {
|
||||
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")
|
||||
}
|
||||
|
||||
// buildExecuteMessage3 汇总当前执行状态和本轮指令。
|
||||
//
|
||||
// 1. 这里只放“当前轮真正会影响决策”的状态,避免 msg3 继续膨胀。
|
||||
// 2. 读工具最近结果只给最新一条摘要,避免旧 observation 重复占上下文。
|
||||
// 3. 最后一行固定落到“本轮指令”,保证模型收尾时注意力还在执行目标上。
|
||||
func buildExecuteMessage3(state *agentmodel.CommonState, ctx *agentmodel.ConversationContext, runtimeUserPrompt string) string {
|
||||
lines := []string{"当前执行状态:"}
|
||||
roughBuildDone := hasExecuteRoughBuildDone(ctx)
|
||||
|
||||
roundUsed, maxRounds := 0, agentmodel.DefaultMaxRounds
|
||||
modeText := "自由执行(无预定义步骤)"
|
||||
activeDomain := ""
|
||||
activePacks := []string{}
|
||||
if state != nil {
|
||||
roundUsed = state.RoundUsed
|
||||
if state.MaxRounds > 0 {
|
||||
maxRounds = state.MaxRounds
|
||||
}
|
||||
if state.HasPlan() {
|
||||
modeText = "计划执行(有预定义步骤)"
|
||||
}
|
||||
activeDomain = strings.TrimSpace(state.ActiveToolDomain)
|
||||
activePacks = readExecuteActiveToolPacks(state)
|
||||
}
|
||||
|
||||
lines = append(lines,
|
||||
fmt.Sprintf("- 当前轮次:%d/%d", roundUsed, maxRounds),
|
||||
"- 当前模式:"+modeText,
|
||||
)
|
||||
|
||||
if activeDomain == "" {
|
||||
lines = append(lines, "- 动态工具区:当前仅激活 context 管理工具。")
|
||||
} else if len(activePacks) == 0 {
|
||||
lines = append(lines, fmt.Sprintf("- 动态工具区:domain=%s,未显式激活 packs。", activeDomain))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("- 动态工具区:domain=%s,packs=[%s]。", activeDomain, strings.Join(activePacks, ",")))
|
||||
}
|
||||
|
||||
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),
|
||||
"- 当前步骤内容:"+stepContent,
|
||||
"- 当前步骤完成判定(done_when):"+doneWhen,
|
||||
"- 动作纪律1:未满足 done_when 时,只能 continue / confirm / ask_user,禁止 next_plan。",
|
||||
"- 动作纪律2:满足 done_when 时,优先 next_plan,并在 goal_check 对照 done_when 给证据。",
|
||||
"- 动作纪律3:禁止跳到后续步骤执行。",
|
||||
)
|
||||
} else {
|
||||
lines = append(lines,
|
||||
"- 当前计划步骤不可读;请先判断是否已完成全部计划。",
|
||||
"- 若已完成全部计划,输出 done 并给出 goal_check 证据。",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if latestAnalyze := renderExecuteLatestAnalyzeSummary(ctx); latestAnalyze != "" {
|
||||
lines = append(lines, "- 最近一次诊断:"+latestAnalyze)
|
||||
}
|
||||
if latestMutation := renderExecuteLatestMutationSummary(ctx); latestMutation != "" {
|
||||
lines = append(lines, "- 最近一次写操作:"+latestMutation)
|
||||
}
|
||||
if taskClassText := renderExecuteTaskClassIDs(state); taskClassText != "" {
|
||||
lines = append(lines, "- 目标任务类:"+taskClassText)
|
||||
}
|
||||
|
||||
lines = append(lines,
|
||||
"- 啥时候结束Loop:你可以根据工具调用记录自行判断。",
|
||||
"- 非目标:不重新粗排、不修改无关任务类。",
|
||||
)
|
||||
if roughBuildDone {
|
||||
lines = append(lines, "- 阶段约束:粗排已完成,本轮只微调 suggested;existing 仅作已安排事实参考,不作为可移动目标。")
|
||||
}
|
||||
lines = append(lines, "- 参数纪律:工具参数必须严格使用 schema 字段;若返回“参数非法”,需先改参再继续。")
|
||||
|
||||
if state != nil {
|
||||
if state.AllowReorder {
|
||||
lines = append(lines, "- 顺序策略:用户已明确允许打乱顺序,但当前主链不再提供顺序重排工具,请优先使用 move/swap 做局部调整。")
|
||||
} else {
|
||||
lines = append(lines, "- 顺序策略:默认保持 suggested 相对顺序,仅做局部 move/swap 调整。")
|
||||
}
|
||||
}
|
||||
|
||||
if upsertRuntime := renderTaskClassUpsertRuntime(state); upsertRuntime != "" {
|
||||
lines = append(lines, "任务类写入运行态:")
|
||||
lines = append(lines, upsertRuntime)
|
||||
}
|
||||
|
||||
if memoryText := renderExecuteMemoryContext(ctx); memoryText != "" {
|
||||
lines = append(lines, "相关记忆(仅在确有帮助时参考,不要机械复述):")
|
||||
lines = append(lines, memoryText)
|
||||
}
|
||||
|
||||
latestAnalyze := renderExecuteLatestAnalyzeSummary(ctx)
|
||||
latestMutation := renderExecuteLatestMutationSummary(ctx)
|
||||
if nextStep := renderExecuteNextStepHintV2(state, latestAnalyze, latestMutation, roughBuildDone); nextStep != "" {
|
||||
lines = append(lines, "下一步提示:")
|
||||
lines = append(lines, "- "+nextStep)
|
||||
}
|
||||
|
||||
instruction := strings.TrimSpace(runtimeUserPrompt)
|
||||
if instruction == "" {
|
||||
instruction = "请继续当前任务执行阶段,严格按 SMARTFLOW_DECISION 标签格式输出。"
|
||||
} else {
|
||||
instruction = firstExecuteLine(instruction)
|
||||
}
|
||||
lines = append(lines, "本轮指令:"+instruction)
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// renderExecuteToolCatalogCompact 将当前 tool schemas 渲染为紧凑简表。
|
||||
//
|
||||
// 1. 这里只给模型最低必要的参数和返回值感知,不重复塞完整 schema JSON。
|
||||
// 2. 对复杂工具额外给一条调用示例,降低“参数字段写错”的概率。
|
||||
// 3. 这里只展示当前真实可用工具,避免历史残留能力继续污染工具面。
|
||||
func renderExecuteToolCatalogCompact(ctx *agentmodel.ConversationContext, state *agentmodel.CommonState) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
schemas := ctx.ToolSchemasSnapshot()
|
||||
if len(schemas) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := []string{"可用工具(简表):"}
|
||||
index := 0
|
||||
for _, schemaItem := range schemas {
|
||||
name := strings.TrimSpace(schemaItem.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
index++
|
||||
desc := strings.TrimSpace(schemaItem.Desc)
|
||||
if desc == "" {
|
||||
desc = "无描述"
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%d. %s:%s", index, name, desc))
|
||||
|
||||
doc := parseExecuteToolSchema(schemaItem.SchemaText)
|
||||
paramSummary := renderExecuteToolParamSummary(doc.Parameters)
|
||||
lines = append(lines, " 参数:"+paramSummary)
|
||||
|
||||
returnType, returnSample := renderExecuteToolReturnHint(name)
|
||||
lines = append(lines, " 返回类型:"+returnType)
|
||||
if shouldRenderExecuteToolReturnSample(name) {
|
||||
lines = append(lines, " 返回示例:"+returnSample)
|
||||
}
|
||||
if callSample := renderExecuteToolCallHint(name); strings.TrimSpace(callSample) != "" {
|
||||
lines = append(lines, " 调用示例:"+callSample)
|
||||
}
|
||||
}
|
||||
|
||||
if index == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func shouldRenderExecuteToolReturnSample(toolName string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(toolName)) {
|
||||
case "query_available_slots",
|
||||
"query_target_tasks",
|
||||
"queue_pop_head",
|
||||
"queue_status",
|
||||
"queue_apply_head_move",
|
||||
"queue_skip_head",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
"analyze_health",
|
||||
"analyze_rhythm",
|
||||
"upsert_task_class":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func renderExecuteToolCallHint(toolName string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(toolName)) {
|
||||
case "upsert_task_class":
|
||||
return `仅当用户或上下文已明确给出日期范围时,才允许写入 start_date/end_date;写前先检查 difficulty_level 已归一为 low/medium/high,items 已非空且内容顺序已生成完成:{"name":"upsert_task_class","arguments":{"task_class":{"name":"线性代数复习","mode":"auto","start_date":"2026-06-01","end_date":"2026-06-20","subject_type":"quantitative","difficulty_level":"high","cognitive_intensity":"high","config":{"total_slots":8,"strategy":"steady","allow_filler_course":false,"excluded_slots":[1,6],"excluded_days_of_week":[6,7]},"items":[{"order":1,"content":"行列式定义与基础计算"},{"order":2,"content":"矩阵及其运算规则"},{"order":3,"content":"逆矩阵与矩阵的秩"}]}}}`
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func renderExecuteToolReturnHint(toolName string) (returnType string, sample string) {
|
||||
returnType = "string(自然语言文本)"
|
||||
switch strings.ToLower(strings.TrimSpace(toolName)) {
|
||||
case "get_overview":
|
||||
return returnType, "规划窗口共27天...课程占位条目34个...任务清单(已过滤课程)..."
|
||||
case "get_task_info":
|
||||
return returnType, "[35] 第一章随机事件与概率 | 状态:已预排(suggested) | 占用时段:第3天第5-6节"
|
||||
case "query_available_slots":
|
||||
return "string(JSON字符串)", `{"tool":"query_available_slots","count":12,"strict_count":8,"embedded_count":4,"slots":[{"day":5,"week":12,"day_of_week":3,"slot_start":1,"slot_end":2,"slot_type":"empty"}]}`
|
||||
case "query_target_tasks":
|
||||
return "string(JSON字符串)", `{"tool":"query_target_tasks","count":6,"status":"suggested","enqueue":true,"enqueued":6,"queue":{"pending_count":6},"items":[{"task_id":35,"name":"示例任务","status":"suggested","slots":[{"day":3,"week":12,"day_of_week":1,"slot_start":5,"slot_end":6}]}]}`
|
||||
case "queue_pop_head":
|
||||
return "string(JSON字符串)", `{"tool":"queue_pop_head","has_head":true,"pending_count":5,"current":{"task_id":35,"name":"示例任务","status":"suggested","slots":[{"day":3,"week":12,"day_of_week":1,"slot_start":5,"slot_end":6}]}}`
|
||||
case "queue_status":
|
||||
return "string(JSON字符串)", `{"tool":"queue_status","pending_count":5,"completed_count":1,"skipped_count":0,"current_task_id":35,"current_attempt":1}`
|
||||
case "queue_apply_head_move":
|
||||
return "string(JSON字符串)", `{"tool":"queue_apply_head_move","success":true,"task_id":35,"pending_count":4,"completed_count":2,"result":"已将 [35]... 从第3天第5-6节移至第5天第3-4节。"}`
|
||||
case "queue_skip_head":
|
||||
return "string(JSON字符串)", `{"tool":"queue_skip_head","success":true,"skipped_task_id":35,"pending_count":4,"skipped_count":1}`
|
||||
case "query_range":
|
||||
return returnType, "第5天第3-6节:第3节空、第4节空..."
|
||||
case "place":
|
||||
return returnType, "已将 [35]... 预排到第5天第3-4节。"
|
||||
case "move":
|
||||
return returnType, "已将 [35]... 从第3天第5-6节移至第5天第3-4节。"
|
||||
case "swap":
|
||||
return returnType, "交换完成:[35]... ↔ [36]..."
|
||||
case "batch_move":
|
||||
return returnType, "批量移动完成,2 个任务全部成功。"
|
||||
case "unplace":
|
||||
return returnType, "已将 [35]... 移除,恢复为待安排状态。"
|
||||
case "web_search":
|
||||
return "string(JSON字符串)", `{"tool":"web_search","query":"检索关键词","count":2,"items":[{"title":"搜索结果标题","url":"https://example.com/page","snippet":"摘要片段...","domain":"example.com","published_at":"2025-04-10"}]}`
|
||||
case "web_fetch":
|
||||
return "string(JSON字符串)", `{"tool":"web_fetch","url":"https://example.com/page","title":"页面标题","content":"正文内容...","truncated":false}`
|
||||
case "analyze_health":
|
||||
return "string(JSON字符串)", `{"tool":"analyze_health","success":true,"metrics":{"rhythm":{"avg_switches_per_day":1.1,"max_switch_count":4,"heavy_adjacent_days":2,"same_type_transition_ratio":0.58,"block_balance":0,"fragmented_count":0,"compressed_run_count":0},"tightness":{"locally_movable_task_count":3,"avg_local_alternative_slots":1.7,"cross_class_swap_options":1,"forced_heavy_adjacent_days":0,"tightness_level":"tight"},"can_close":false},"decision":{"should_continue_optimize":true,"recommended_operation":"swap","primary_problem":"第4天存在高认知背靠背","candidates":[{"candidate_id":"swap_35_44","tool":"swap","arguments":{"task_a":35,"task_b":44}}]}}`
|
||||
case "analyze_rhythm":
|
||||
return "string(JSON字符串)", `{"tool":"analyze_rhythm","success":true,"metrics":{"overview":{"avg_switches_per_day":3.4,"max_switch_day":4,"max_switch_count":5,"heavy_adjacent_days":2,"long_high_intensity_days":1,"same_type_transition_ratio":0.42}}}`
|
||||
case "upsert_task_class":
|
||||
return "string(JSON字符串)", `{"tool":"upsert_task_class","success":true,"task_class_id":123,"created":true,"validation":{"ok":true,"issues":[]},"error":"","error_code":""}`
|
||||
default:
|
||||
return returnType, "自然语言结果(成功/失败原因/关键数据摘要)。"
|
||||
}
|
||||
}
|
||||
|
||||
func parseExecuteToolSchema(schemaText string) executeToolSchemaDoc {
|
||||
doc := executeToolSchemaDoc{Parameters: map[string]any{}}
|
||||
schemaText = strings.TrimSpace(schemaText)
|
||||
if schemaText == "" {
|
||||
return doc
|
||||
}
|
||||
if err := json.Unmarshal([]byte(schemaText), &doc); err != nil {
|
||||
return doc
|
||||
}
|
||||
if doc.Parameters == nil {
|
||||
doc.Parameters = map[string]any{}
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
func renderExecuteToolParamSummary(parameters map[string]any) string {
|
||||
if len(parameters) == 0 {
|
||||
return "{}"
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(parameters))
|
||||
for key := range parameters {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
status := "可选"
|
||||
typeText := ""
|
||||
|
||||
switch typed := parameters[key].(type) {
|
||||
case string:
|
||||
status = "必填"
|
||||
typeText = strings.TrimSpace(typed)
|
||||
case map[string]any:
|
||||
if required, ok := typed["required"].(bool); ok && required {
|
||||
status = "必填"
|
||||
}
|
||||
typeText = strings.TrimSpace(asExecuteString(typed["type"]))
|
||||
if enumRaw, ok := typed["enum"].([]any); ok && len(enumRaw) > 0 {
|
||||
enumText := make([]string, 0, len(enumRaw))
|
||||
for _, item := range enumRaw {
|
||||
enumText = append(enumText, fmt.Sprintf("%v", item))
|
||||
}
|
||||
if typeText == "" {
|
||||
typeText = "enum"
|
||||
}
|
||||
typeText += ":" + strings.Join(enumText, "/")
|
||||
}
|
||||
}
|
||||
|
||||
if typeText == "" {
|
||||
parts = append(parts, fmt.Sprintf("%s(%s)", key, status))
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s(%s,%s)", key, status, typeText))
|
||||
}
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
|
||||
// collectExecuteLoopRecords 从 history 里提取 thought + tool_call + observation 三元组。
|
||||
//
|
||||
// 1. 以 assistant tool_call 为主记录。
|
||||
// 2. 用 ToolCallID 去关联 tool observation,保证同轮绑定。
|
||||
// 3. thought 只向前取最近一条 assistant 纯文本消息,不跨越到更早的工具调用之前做复杂回溯。
|
||||
func collectExecuteLoopRecords(history []*schema.Message) []executeLoopRecord {
|
||||
if len(history) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
toolResultByCallID := make(map[string]*schema.Message, len(history))
|
||||
for _, msg := range history {
|
||||
if msg == nil || msg.Role != schema.Tool {
|
||||
continue
|
||||
}
|
||||
callID := strings.TrimSpace(msg.ToolCallID)
|
||||
if callID == "" {
|
||||
continue
|
||||
}
|
||||
toolResultByCallID[callID] = msg
|
||||
}
|
||||
|
||||
records := make([]executeLoopRecord, 0, len(history))
|
||||
for i, msg := range history {
|
||||
if msg == nil || msg.Role != schema.Assistant || len(msg.ToolCalls) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
thought := findExecuteThoughtBefore(history, i)
|
||||
for _, call := range msg.ToolCalls {
|
||||
toolName := strings.TrimSpace(call.Function.Name)
|
||||
if toolName == "" {
|
||||
toolName = "unknown_tool"
|
||||
}
|
||||
|
||||
toolArgs := compactExecuteText(call.Function.Arguments, 160)
|
||||
if toolArgs == "" {
|
||||
toolArgs = "{}"
|
||||
}
|
||||
|
||||
observation := "该工具调用尚未返回结果。"
|
||||
callID := strings.TrimSpace(call.ID)
|
||||
if callID != "" {
|
||||
if resultMsg, ok := toolResultByCallID[callID]; ok && resultMsg != nil {
|
||||
text := strings.TrimSpace(resultMsg.Content)
|
||||
if text != "" {
|
||||
observation = text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
records = append(records, executeLoopRecord{
|
||||
Thought: thought,
|
||||
ToolName: toolName,
|
||||
ToolArgs: toolArgs,
|
||||
Observation: observation,
|
||||
})
|
||||
}
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func findExecuteThoughtBefore(history []*schema.Message, index int) string {
|
||||
for i := index - 1; i >= 0; i-- {
|
||||
msg := history[i]
|
||||
if msg == nil || msg.Role != schema.Assistant {
|
||||
continue
|
||||
}
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
continue
|
||||
}
|
||||
content := compactExecuteText(msg.Content, 140)
|
||||
if content != "" {
|
||||
return content
|
||||
}
|
||||
}
|
||||
return "(未记录)"
|
||||
}
|
||||
|
||||
func renderExecuteToolCallText(toolName, toolArgs string) string {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName == "" {
|
||||
toolName = "unknown_tool"
|
||||
}
|
||||
toolArgs = strings.TrimSpace(toolArgs)
|
||||
if toolArgs == "" {
|
||||
toolArgs = "{}"
|
||||
}
|
||||
return toolName + "(" + toolArgs + ")"
|
||||
}
|
||||
|
||||
func hasExecuteRoughBuildDone(ctx *agentmodel.ConversationContext) bool {
|
||||
if ctx == nil {
|
||||
return false
|
||||
}
|
||||
for _, block := range ctx.PinnedBlocksSnapshot() {
|
||||
if strings.TrimSpace(block.Key) == "rough_build_done" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func renderExecuteLatestAnalyzeSummary(ctx *agentmodel.ConversationContext) string {
|
||||
record, ok := findExecuteLatestToolRecord(ctx, map[string]struct{}{
|
||||
"analyze_health": {},
|
||||
"analyze_rhythm": {},
|
||||
})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s -> %s", record.ToolName, record.Observation)
|
||||
}
|
||||
|
||||
func renderExecuteLatestMutationSummary(ctx *agentmodel.ConversationContext) string {
|
||||
record, ok := findExecuteLatestToolRecord(ctx, map[string]struct{}{
|
||||
"place": {},
|
||||
"move": {},
|
||||
"swap": {},
|
||||
"batch_move": {},
|
||||
"unplace": {},
|
||||
"queue_apply_head_move": {},
|
||||
})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s -> %s", record.ToolName, record.Observation)
|
||||
}
|
||||
|
||||
func findExecuteLatestToolRecord(ctx *agentmodel.ConversationContext, allowSet map[string]struct{}) (executeLatestToolRecord, bool) {
|
||||
if ctx == nil || len(allowSet) == 0 {
|
||||
return executeLatestToolRecord{}, false
|
||||
}
|
||||
history := ctx.HistorySnapshot()
|
||||
if len(history) == 0 {
|
||||
return executeLatestToolRecord{}, false
|
||||
}
|
||||
|
||||
toolNameByCallID := make(map[string]string, len(history))
|
||||
for _, msg := range history {
|
||||
if msg == nil || msg.Role != schema.Assistant || len(msg.ToolCalls) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, call := range msg.ToolCalls {
|
||||
callID := strings.TrimSpace(call.ID)
|
||||
toolName := strings.TrimSpace(call.Function.Name)
|
||||
if callID == "" || toolName == "" {
|
||||
continue
|
||||
}
|
||||
toolNameByCallID[callID] = toolName
|
||||
}
|
||||
}
|
||||
|
||||
for i := len(history) - 1; i >= 0; i-- {
|
||||
msg := history[i]
|
||||
if msg == nil || msg.Role != schema.Tool {
|
||||
continue
|
||||
}
|
||||
callID := strings.TrimSpace(msg.ToolCallID)
|
||||
if callID == "" {
|
||||
continue
|
||||
}
|
||||
toolName := strings.TrimSpace(toolNameByCallID[callID])
|
||||
if toolName == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowSet[toolName]; !ok {
|
||||
continue
|
||||
}
|
||||
return executeLatestToolRecord{
|
||||
ToolName: toolName,
|
||||
Observation: summarizeExecuteToolObservation(msg.Content),
|
||||
}, true
|
||||
}
|
||||
|
||||
return executeLatestToolRecord{}, false
|
||||
}
|
||||
|
||||
func summarizeExecuteToolObservation(raw string) string {
|
||||
content := strings.TrimSpace(raw)
|
||||
if content == "" {
|
||||
return "无返回内容。"
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(content), &payload); err == nil && len(payload) > 0 {
|
||||
if toolName := strings.TrimSpace(asExecuteString(payload["tool"])); toolName == "analyze_health" {
|
||||
return summarizeExecuteAnalyzeHealthObservationV2(payload)
|
||||
}
|
||||
for _, key := range []string{"result", "message", "reason", "error"} {
|
||||
if text := strings.TrimSpace(asExecuteString(payload[key])); text != "" {
|
||||
return compactExecuteText(text, 120)
|
||||
}
|
||||
}
|
||||
if success, ok := payload["success"].(bool); ok {
|
||||
if success {
|
||||
return "执行成功。"
|
||||
}
|
||||
return "执行失败。"
|
||||
}
|
||||
}
|
||||
|
||||
return compactExecuteText(content, 120)
|
||||
}
|
||||
|
||||
// collectExecuteConversationTurns 只提取 user 和 assistant speak。
|
||||
//
|
||||
// 1. 过滤 correction prompt,避免把后端纠错提示伪装成用户真实意图。
|
||||
// 2. 过滤 assistant tool_call 消息,避免 msg1 和 msg2 重复。
|
||||
// 3. 保持原始顺序,不在这里裁剪长度。
|
||||
func collectExecuteConversationTurns(history []*schema.Message) []conversationTurn {
|
||||
if len(history) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
turns := make([]conversationTurn, 0, len(history))
|
||||
for _, msg := range history {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(msg.Content)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
switch msg.Role {
|
||||
case schema.User:
|
||||
if isExecuteCorrectionPrompt(msg) {
|
||||
continue
|
||||
}
|
||||
turns = append(turns, conversationTurn{Role: "user", Content: text})
|
||||
case schema.Assistant:
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
continue
|
||||
}
|
||||
turns = append(turns, conversationTurn{Role: "assistant", Content: text})
|
||||
}
|
||||
}
|
||||
|
||||
return turns
|
||||
}
|
||||
|
||||
func isExecuteCorrectionPrompt(msg *schema.Message) bool {
|
||||
if msg == nil || msg.Role != schema.User {
|
||||
return false
|
||||
}
|
||||
if msg.Extra != nil {
|
||||
if kind, ok := msg.Extra[executeHistoryKindKey].(string); ok && strings.TrimSpace(kind) == executeHistoryKindCorrectionUser {
|
||||
return true
|
||||
}
|
||||
}
|
||||
content := strings.TrimSpace(msg.Content)
|
||||
return strings.Contains(content, "请重新分析当前状态,输出正确的内容。")
|
||||
}
|
||||
|
||||
func compactExecuteText(content string, maxLen int) string {
|
||||
content = firstExecuteLine(content)
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(content)
|
||||
if len(runes) <= maxLen {
|
||||
return content
|
||||
}
|
||||
if maxLen <= 3 {
|
||||
return string(runes[:maxLen])
|
||||
}
|
||||
return string(runes[:maxLen-3]) + "..."
|
||||
}
|
||||
|
||||
func firstExecuteLine(content string) string {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(content, "\n")
|
||||
return strings.TrimSpace(lines[0])
|
||||
}
|
||||
|
||||
func asExecuteString(value any) string {
|
||||
if text, ok := value.(string); ok {
|
||||
return text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func renderExecuteTaskClassIDs(state *agentmodel.CommonState) string {
|
||||
if state == nil || len(state.TaskClassIDs) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := make([]string, len(state.TaskClassIDs))
|
||||
for i, id := range state.TaskClassIDs {
|
||||
parts[i] = strconv.Itoa(id)
|
||||
}
|
||||
return fmt.Sprintf("task_class_ids=[%s]", strings.Join(parts, ","))
|
||||
}
|
||||
|
||||
// renderExecuteMemoryContext 复用统一记忆入口,避免 execute 私自拼接其他 pinned block。
|
||||
func renderExecuteMemoryContext(ctx *agentmodel.ConversationContext) string {
|
||||
return renderUnifiedMemoryContext(ctx)
|
||||
}
|
||||
|
||||
func renderTaskClassUpsertRuntime(state *agentmodel.CommonState) string {
|
||||
if state == nil || !state.TaskClassUpsertLastTried {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := make([]string, 0, 4)
|
||||
if state.TaskClassUpsertLastSuccess {
|
||||
lines = append(lines, "- 最近一次 upsert_task_class 成功。")
|
||||
} else {
|
||||
lines = append(lines, "- 最近一次 upsert_task_class 失败。")
|
||||
}
|
||||
if state.TaskClassUpsertConsecutiveFailures > 0 {
|
||||
lines = append(lines, fmt.Sprintf("- 连续失败次数:%d", state.TaskClassUpsertConsecutiveFailures))
|
||||
}
|
||||
if len(state.TaskClassUpsertLastIssues) > 0 {
|
||||
lines = append(lines, "- 需要优先处理 validation.issues:")
|
||||
for _, issue := range state.TaskClassUpsertLastIssues {
|
||||
trimmed := strings.TrimSpace(issue)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, " - "+trimmed)
|
||||
}
|
||||
}
|
||||
if !state.TaskClassUpsertLastSuccess {
|
||||
lines = append(lines, "- 写前最少检查项:mode=auto 的 start_date/end_date、subject_type/difficulty_level/cognitive_intensity、difficulty_level 合法枚举、items 非空且内容已生成、config 约束字段合法。")
|
||||
lines = append(lines, "- 先判断当前 issues 属于哪一类:若是 schema 字段名、字段位置、半天块索引、枚举值、日期格式、工具语义映射等内部表示问题,直接静默改参重试。")
|
||||
lines = append(lines, "- 若 issue 指向 start_date/end_date 等字段,先检查当前对话、历史、记忆、最近工具结果里是否已出现可用值;只有确实没有时再 ask_user。")
|
||||
lines = append(lines, "- 若缺的是 start_date/end_date/日期范围/开始日期承诺/完成期限,而这些值并未在上下文中出现,就必须 ask_user;不能把当前日期或默认周期当成用户已同意的时间边界。")
|
||||
lines = append(lines, "- 若 issue 像 difficulty_level 非法、items 为空、约束字段格式不合法,就先在本轮静默归一/补齐/生成,再 confirm 重试;不要把 validation 当试错器。")
|
||||
lines = append(lines, "- 若再次调用 upsert_task_class,动作必须是 confirm,不能输出 continue + tool_call。")
|
||||
lines = append(lines, "- 在 issues 处理完之前,不要用 done 收口。")
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
33
backend/services/agent/prompt/execute_context_health.go
Normal file
33
backend/services/agent/prompt/execute_context_health.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func fallbackExecuteText(value string, fallback string) string {
|
||||
if text := strings.TrimSpace(value); text != "" {
|
||||
return text
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func compactHealthAny(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
case bool:
|
||||
if typed {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case int:
|
||||
return fmt.Sprintf("%d", typed)
|
||||
case float64:
|
||||
return fmt.Sprintf("%.0f", typed)
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
}
|
||||
106
backend/services/agent/prompt/execute_context_health_v2.go
Normal file
106
backend/services/agent/prompt/execute_context_health_v2.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// summarizeExecuteAnalyzeHealthObservationV2 把 analyze_health 结果压成更短的单行摘要。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只保留 execute 下一步真正需要消费的裁决字段,不重复展开整份 metrics。
|
||||
// 2. 若存在候选,会优先展示“候选数量 + 前两个候选工具”,帮助模型迅速进入选择题。
|
||||
// 3. 这里只做摘要,不负责改变决策含义;真实判定仍以 analyze_health 原始 JSON 为准。
|
||||
func summarizeExecuteAnalyzeHealthObservationV2(payload map[string]any) string {
|
||||
decision, _ := payload["decision"].(map[string]any)
|
||||
metrics, _ := payload["metrics"].(map[string]any)
|
||||
rhythmMetrics, _ := metrics["rhythm"].(map[string]any)
|
||||
tightnessMetrics, _ := metrics["tightness"].(map[string]any)
|
||||
candidates, _ := decision["candidates"].([]any)
|
||||
|
||||
parts := make([]string, 0, 7)
|
||||
if text := compactHealthAny(decision["should_continue_optimize"]); text != "" {
|
||||
parts = append(parts, "continue="+text)
|
||||
}
|
||||
if text := strings.TrimSpace(asExecuteString(decision["recommended_operation"])); text != "" {
|
||||
parts = append(parts, "recommended="+text)
|
||||
}
|
||||
if text := strings.TrimSpace(asExecuteString(tightnessMetrics["tightness_level"])); text != "" {
|
||||
parts = append(parts, "tightness="+text)
|
||||
}
|
||||
if text := buildBlockBalanceSummary(rhythmMetrics); text != "" {
|
||||
parts = append(parts, text)
|
||||
}
|
||||
if text := compactHealthAny(decision["is_forced_imperfection"]); text != "" {
|
||||
parts = append(parts, "forced="+text)
|
||||
}
|
||||
if len(candidates) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("candidates=%d", len(candidates)))
|
||||
if preview := compactHealthCandidatePreview(candidates); preview != "" {
|
||||
parts = append(parts, "options="+preview)
|
||||
}
|
||||
}
|
||||
if text := strings.TrimSpace(asExecuteString(decision["primary_problem"])); text != "" {
|
||||
parts = append(parts, "problem="+compactExecuteText(text, 36))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "返回了健康裁决结果。"
|
||||
}
|
||||
return strings.Join(parts, " | ")
|
||||
}
|
||||
|
||||
// buildBlockBalanceSummary 把 block_balance 连同正负来源一起压成单段摘要。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只做 execute 摘要层的可读性补充,避免 LLM 只看到 balance=0 却看不到来源。
|
||||
// 2. 不改变 analyze_health 原始 JSON 结构;原始结构仍由 metrics.rhythm 提供完整字段。
|
||||
// 3. 若三个字段都缺失,则直接留空,避免构造误导性的默认值。
|
||||
func buildBlockBalanceSummary(rhythmMetrics map[string]any) string {
|
||||
if len(rhythmMetrics) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
blockBalance := compactHealthAny(rhythmMetrics["block_balance"])
|
||||
fragmentedCount := compactHealthAny(rhythmMetrics["fragmented_count"])
|
||||
compressedCount := compactHealthAny(rhythmMetrics["compressed_run_count"])
|
||||
if blockBalance == "" && fragmentedCount == "" && compressedCount == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"block_balance=%s(fragmented=%s,compressed=%s)",
|
||||
fallbackExecuteText(blockBalance, "?"),
|
||||
fallbackExecuteText(fragmentedCount, "?"),
|
||||
fallbackExecuteText(compressedCount, "?"),
|
||||
)
|
||||
}
|
||||
|
||||
func compactHealthCandidatePreview(candidates []any) string {
|
||||
if len(candidates) == 0 {
|
||||
return ""
|
||||
}
|
||||
preview := make([]string, 0, 2)
|
||||
for _, raw := range candidates {
|
||||
item, _ := raw.(map[string]any)
|
||||
if len(item) == 0 {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(asExecuteString(item["candidate_id"]))
|
||||
tool := strings.TrimSpace(asExecuteString(item["tool"]))
|
||||
if id == "" && tool == "" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case id != "" && tool != "":
|
||||
preview = append(preview, id+":"+tool)
|
||||
case id != "":
|
||||
preview = append(preview, id)
|
||||
default:
|
||||
preview = append(preview, tool)
|
||||
}
|
||||
if len(preview) >= 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(preview, ",")
|
||||
}
|
||||
104
backend/services/agent/prompt/execute_next_step_hint_v2.go
Normal file
104
backend/services/agent/prompt/execute_next_step_hint_v2.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agenttools "github.com/LoveLosita/smartflow/backend/services/agent/tools"
|
||||
)
|
||||
|
||||
// renderExecuteNextStepHintV2 生成 execute.msg3 的轻量方向提示。
|
||||
//
|
||||
// 设计目标:
|
||||
// 1. 主动优化模式下,只强调“先 analyze_health,再从 candidates 里选”,不再散发额外搜索暗示。
|
||||
// 2. 普通链路仍保留必要的业务引导,避免误伤用户明确提出的普通调整请求。
|
||||
// 3. 提示只给方向,不替模型代填最终写参数。
|
||||
func renderExecuteNextStepHintV2(
|
||||
state *agentmodel.CommonState,
|
||||
latestAnalyze string,
|
||||
latestMutation string,
|
||||
roughBuildDone bool,
|
||||
) string {
|
||||
if state == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
activeDomain := strings.TrimSpace(state.ActiveToolDomain)
|
||||
activePacks := agenttools.ResolveEffectiveToolPacks(state.ActiveToolDomain, state.ActiveToolPacks)
|
||||
|
||||
if state.ActiveOptimizeOnly {
|
||||
switch {
|
||||
case activeDomain == "" && roughBuildDone:
|
||||
return "当前是粗排后主动优化专用模式;先激活 schedule,并只围绕 analyze_health -> move/swap 候选闭环推进。"
|
||||
case !state.HealthCheckDone:
|
||||
return "当前是粗排后主动优化专用模式;先调 analyze_health,等待后端给出 candidates,再做选择。"
|
||||
case !state.HealthIsFeasible || strings.EqualFold(strings.TrimSpace(state.HealthRecommendedOperation), "ask_user"):
|
||||
return "analyze_health 已判定当前更像时间窗或信息约束问题;不要继续挪动,先把冲突或缺失点明确告诉用户。"
|
||||
case !state.HealthShouldContinueOptimize:
|
||||
return "analyze_health 已判定当前无需继续主动优化;若用户没有新增要求,直接收口。"
|
||||
default:
|
||||
return "当前是粗排后主动优化专用模式;直接从 analyze_health 的 decision.candidates 里选一个合法 move/swap 执行,不要再自己搜索读工具。"
|
||||
}
|
||||
}
|
||||
|
||||
if activeDomain == "schedule" && state.HealthCheckDone {
|
||||
switch {
|
||||
case !state.HealthShouldContinueOptimize && state.HealthIsForcedImperfection:
|
||||
return fmt.Sprintf(
|
||||
"analyze_health 已判定当前更像约束代价:tightness=%s,主问题=%s。优先考虑收口。",
|
||||
fallbackExecuteText(state.HealthTightnessLevel, "unknown"),
|
||||
fallbackExecuteText(state.HealthPrimaryProblem, "无"),
|
||||
)
|
||||
case !state.HealthShouldContinueOptimize:
|
||||
return fmt.Sprintf(
|
||||
"analyze_health 已判定当前没有更值得继续处理的局部问题:%s。若用户未追加新要求,优先收口。",
|
||||
fallbackExecuteText(state.HealthPrimaryProblem, "当前可直接收口"),
|
||||
)
|
||||
case state.HealthStagnationCount > 0:
|
||||
return fmt.Sprintf(
|
||||
"最近诊断已连续 %d 次无明显改善;若本轮仍不能让主问题变轻,优先收口。当前主问题:%s。",
|
||||
state.HealthStagnationCount,
|
||||
fallbackExecuteText(state.HealthPrimaryProblem, "无"),
|
||||
)
|
||||
case strings.EqualFold(strings.TrimSpace(state.HealthRecommendedOperation), "swap"):
|
||||
return fmt.Sprintf(
|
||||
"当前主问题:%s。优先在已有落位之间做局部 swap,别把问题扩散到更远的天数。",
|
||||
fallbackExecuteText(state.HealthPrimaryProblem, "无"),
|
||||
)
|
||||
case strings.EqualFold(strings.TrimSpace(state.HealthRecommendedOperation), "move"):
|
||||
return fmt.Sprintf(
|
||||
"当前主问题:%s。若要 move,只在近范围合法落点里小修,不要做全窗口搜索。",
|
||||
fallbackExecuteText(state.HealthPrimaryProblem, "无"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if activeDomain == "" {
|
||||
if roughBuildDone {
|
||||
return `先激活 schedule 业务域;当前是粗排后的微调场景,通常至少需要 mutation+analyze。若要按统一条件逐个处理一批任务,再加 packs=["queue"]。`
|
||||
}
|
||||
return `先判断当前任务属于哪个业务域,再用 context_tools_add 激活对应工具。若用户只是在描述学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,默认先走 taskclass;只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才切 schedule。`
|
||||
}
|
||||
|
||||
if activeDomain == "schedule" &&
|
||||
strings.Contains(latestMutation, "batch_move") &&
|
||||
(strings.Contains(latestMutation, "缺少") || strings.Contains(latestMutation, "无效")) {
|
||||
return `当前 batch_move 路径受参数约束;若要处理一批符合同一条件的任务,优先加 packs=["queue"] 逐个处理。`
|
||||
}
|
||||
|
||||
if activeDomain == "schedule" &&
|
||||
latestAnalyze != "" &&
|
||||
strings.Contains(latestAnalyze, "metrics") &&
|
||||
!containsExecutePack(activePacks, agenttools.ToolPackQueue) {
|
||||
return `若诊断已经完成,下一步应转入读事实或写操作,不要重复 analyze_health;涉及同类批量任务时优先考虑 packs=["queue"]。`
|
||||
}
|
||||
|
||||
if activeDomain == "taskclass" &&
|
||||
state.TaskClassUpsertLastTried &&
|
||||
!state.TaskClassUpsertLastSuccess {
|
||||
return `先判断 validation.issues 是“用户缺信息”还是“内部表示修正”;能从上下文补的先静默补齐,再用 confirm 重试 upsert_task_class,不要继续解释底层约束,更不要直接收口。`
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
350
backend/services/agent/prompt/execute_rule_packs.go
Normal file
350
backend/services/agent/prompt/execute_rule_packs.go
Normal file
@@ -0,0 +1,350 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
agenttools "github.com/LoveLosita/smartflow/backend/services/agent/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
executeRulePackCoreMin = "core_min"
|
||||
executeRulePackSafetyHard = "safety_hard"
|
||||
executeRulePackContextProtocol = "context_protocol"
|
||||
executeRulePackModePlan = "mode_plan"
|
||||
executeRulePackModeReAct = "mode_react"
|
||||
executeRulePackDomainSchedule = "domain_schedule"
|
||||
executeRulePackDomainTaskClass = "domain_taskclass"
|
||||
executeRulePackScheduleMutation = "schedule_mutation"
|
||||
executeRulePackScheduleAnalyze = "schedule_analyze"
|
||||
executeRulePackScheduleWeb = "schedule_web"
|
||||
executeRulePackMicroRoughDone = "micro_rough_build_done"
|
||||
executeRulePackMicroDiagLoop = "micro_diag_tune_loop"
|
||||
executeRulePackMicroQueue = "micro_queue_chain"
|
||||
executeRulePackMicroTaskRetry = "micro_taskclass_retry"
|
||||
)
|
||||
|
||||
const executeSystemPromptBaseWithPlan = `
|
||||
你叫 SmartMate,是时伴(SmartMate)的中文 AI 排程伙伴,面向大学生提供陪伴式日程管理与日常协助。
|
||||
你擅长课表与任务安排、任务管理、学习规划和随口记,也可以正常回答日常问答、生活建议、信息整理、分析讨论等非排程问题。
|
||||
你的目标是像一个越用越懂用户的伙伴一样,结合历史对话、长期记忆和当前上下文,给出贴心、清晰、可信的帮助。
|
||||
你当前处于“计划执行”模式。你必须围绕当前计划步骤推进,并通过 SMARTFLOW_DECISION 输出结构化动作。`
|
||||
|
||||
const executeSystemPromptBaseReAct = `
|
||||
你叫 SmartMate,是时伴(SmartMate)的中文 AI 排程伙伴,面向大学生提供陪伴式日程管理与日常协助。
|
||||
你擅长课表与任务安排、任务管理、学习规划和随口记,也可以正常回答日常问答、生活建议、信息整理、分析讨论等非排程问题。
|
||||
你的目标是像一个越用越懂用户的伙伴一样,结合历史对话、长期记忆和当前上下文,给出贴心、清晰、可信的帮助。
|
||||
你当前处于“自由执行(ReAct)”模式。你需要根据当前目标自主推进、按需调用工具,并通过 SMARTFLOW_DECISION 输出结构化动作。`
|
||||
|
||||
type executeRulePack struct {
|
||||
Name string
|
||||
Content string
|
||||
}
|
||||
|
||||
// renderExecuteRulePackSection 渲染 execute.msg0 的动态规则包区域。
|
||||
//
|
||||
// 1. 这里负责“选哪些包 + 以什么顺序展示”,不负责工具目录本身。
|
||||
// 2. 固定先放通用硬约束,再放 mode/domain/micro 包,保证模型先读边界后读特例。
|
||||
// 3. 如果没有任何可展示规则包,则直接返回空串,避免无意义占位。
|
||||
func renderExecuteRulePackSection(state *agentmodel.CommonState, ctx *agentmodel.ConversationContext) (string, []string) {
|
||||
packs := selectExecuteRulePacks(state, ctx)
|
||||
if len(packs) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
lines := []string{"执行规则包(msg0 动态注入):"}
|
||||
names := make([]string, 0, len(packs))
|
||||
for _, pack := range packs {
|
||||
content := strings.TrimSpace(pack.Content)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("[%s]", pack.Name))
|
||||
lines = append(lines, content)
|
||||
names = append(names, pack.Name)
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return strings.Join(lines, "\n"), names
|
||||
}
|
||||
|
||||
func selectExecuteRulePacks(state *agentmodel.CommonState, ctx *agentmodel.ConversationContext) []executeRulePack {
|
||||
selected := make([]executeRulePack, 0, 8)
|
||||
seen := map[string]bool{}
|
||||
|
||||
appendPack := func(pack executeRulePack) {
|
||||
name := strings.TrimSpace(pack.Name)
|
||||
if name == "" || seen[name] {
|
||||
return
|
||||
}
|
||||
seen[name] = true
|
||||
selected = append(selected, pack)
|
||||
}
|
||||
|
||||
appendPack(buildExecuteCoreMinPack())
|
||||
appendPack(buildExecuteSafetyHardPack())
|
||||
appendPack(buildExecuteContextProtocolPack())
|
||||
|
||||
if state != nil && state.HasPlan() {
|
||||
appendPack(buildExecuteModePlanPack())
|
||||
} else {
|
||||
appendPack(buildExecuteModeReActPack())
|
||||
}
|
||||
|
||||
switch normalizeExecuteToolDomain(readExecuteActiveToolDomain(state)) {
|
||||
case "schedule":
|
||||
activePacks := readExecuteActiveToolPacks(state)
|
||||
appendPack(buildExecuteSchedulePack())
|
||||
if hasExecutePack(activePacks, agenttools.ToolPackQueue) {
|
||||
appendPack(buildExecuteQueueMicroPack())
|
||||
}
|
||||
if hasExecutePack(activePacks, agenttools.ToolPackMutation) {
|
||||
appendPack(buildExecuteScheduleMutationPack())
|
||||
}
|
||||
if hasExecutePack(activePacks, agenttools.ToolPackAnalyze) {
|
||||
appendPack(buildExecuteScheduleAnalyzePackV2())
|
||||
}
|
||||
if hasExecutePack(activePacks, agenttools.ToolPackWeb) {
|
||||
appendPack(buildExecuteScheduleWebPack())
|
||||
}
|
||||
case "taskclass":
|
||||
appendPack(buildExecuteTaskClassPack())
|
||||
}
|
||||
|
||||
if hasExecuteRoughBuildDone(ctx) {
|
||||
appendPack(buildExecuteRoughDoneMicroPack())
|
||||
}
|
||||
if shouldInjectExecuteDiagLoopPack(state, ctx) {
|
||||
appendPack(buildExecuteDiagLoopMicroPackV2())
|
||||
}
|
||||
if state != nil && state.TaskClassUpsertLastTried && !state.TaskClassUpsertLastSuccess {
|
||||
appendPack(buildExecuteTaskClassRetryMicroPack())
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
func readExecuteActiveToolDomain(state *agentmodel.CommonState) string {
|
||||
if state == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(state.ActiveToolDomain)
|
||||
}
|
||||
|
||||
func readExecuteActiveToolPacks(state *agentmodel.CommonState) []string {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
return agenttools.ResolveEffectiveToolPacks(state.ActiveToolDomain, state.ActiveToolPacks)
|
||||
}
|
||||
|
||||
func hasExecutePack(packs []string, target string) bool {
|
||||
target = strings.ToLower(strings.TrimSpace(target))
|
||||
if target == "" {
|
||||
return false
|
||||
}
|
||||
for _, pack := range packs {
|
||||
if strings.ToLower(strings.TrimSpace(pack)) == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// containsExecutePack 兼容旧调用点。
|
||||
//
|
||||
// 1. 这里只做别名转发,不引入第二套判断口径。
|
||||
// 2. 保留它是为了避免下一轮再因为历史调用点而误删。
|
||||
func containsExecutePack(packs []string, target string) bool {
|
||||
return hasExecutePack(packs, target)
|
||||
}
|
||||
|
||||
func normalizeExecuteToolDomain(domain string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(domain)) {
|
||||
case "schedule":
|
||||
return "schedule"
|
||||
case "taskclass":
|
||||
return "taskclass"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteCoreMinPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackCoreMin,
|
||||
Content: strings.TrimSpace(fmt.Sprintf(`
|
||||
- 当前时间锚点:%s。涉及“今天/明天/本周”等相对时间时,先按该锚点换算。
|
||||
- 用户意图优先:只推进用户当前明确要求;未明确部分先看能否从当前对话、历史、记忆、已知工具结果里静默补齐,只有补不出来时再 ask_user。
|
||||
- 域切换要克制:用户若只是在描述学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,这默认仍是 taskclass,不要主动切到 schedule。
|
||||
- 只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才允许进入 schedule 或触发粗排。
|
||||
- 先事实后动作:优先读工具补齐事实,再决定下一步。
|
||||
- 只要决定调用任何写工具,就必须输出 action=confirm;continue + 写工具无效。这个纪律同样适用于 upsert_task_class 的每一次重试。
|
||||
- 输出格式固定:先 <SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>,再输出用户可见正文。`,
|
||||
buildExecuteNowAnchorLine())),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteNowAnchorLine() string {
|
||||
now := time.Now()
|
||||
weekdays := []string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"}
|
||||
return fmt.Sprintf("%s(%s,%s)", now.Format("2006-01-02 15:04:05 -07:00"), weekdays[int(now.Weekday())], now.Format("MST"))
|
||||
}
|
||||
|
||||
func buildExecuteSafetyHardPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackSafetyHard,
|
||||
Content: strings.TrimSpace(`
|
||||
- 严禁伪造工具结果;若新结果与既有事实冲突,先重查一次再决定。
|
||||
- P1 阶段禁止调用 min_context_switch。
|
||||
- 工具参数必须严格使用 schema 字段名,禁止自造别名。
|
||||
- JSON 只保留当前 action 必需字段;不要输出空字符串、空对象、空数组或 null 占位。
|
||||
- 连续两轮同类读查询后,必须转执行 / ask_user / 明确说明阻塞,不能无限空转。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteContextProtocolPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackContextProtocol,
|
||||
Content: strings.TrimSpace(`
|
||||
- msg0 动态区初始仅保留 context_tools_add / context_tools_remove。
|
||||
- 需要业务工具前先 context_tools_add:排程用 domain="schedule",任务类写入用 domain="taskclass"。
|
||||
- 切 schedule 前先判断用户是否明确提出排程诉求;若只是描述任务类内容与排程偏好,先留在 taskclass。
|
||||
- schedule 可选 packs=["mutation","analyze","detail_read","deep_analyze","queue","web"];core 固定注入,不要显式传 core。
|
||||
- 只在业务方向切换时再 remove;done 后的动态区清理由系统自动完成,不必手动 remove。
|
||||
- 如果目标工具当前不在可用列表,先 add 对应 domain / packs,再继续执行。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteModePlanPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackModePlan,
|
||||
Content: strings.TrimSpace(`
|
||||
- 当前为计划执行模式:必须围绕当前计划步骤推进。
|
||||
- 未满足 done_when 时,只能 continue / confirm / ask_user,禁止 next_plan。
|
||||
- next_plan / done 时,goal_check 必须是字符串,并对照 done_when 给出完成证据。
|
||||
- 禁止跳步执行后续计划。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteModeReActPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackModeReAct,
|
||||
Content: strings.TrimSpace(`
|
||||
- 当前为自由执行(ReAct)模式:可自主决定 continue / confirm / ask_user / done / abort。
|
||||
- 如果关键事实既无法通过工具补齐,也无法从当前对话、历史、记忆中补齐,才 ask_user;不要把本可静默修正的内部表示问题转嫁给用户。
|
||||
- 自主推进时要小步快跑,优先闭合当前局部问题,不要发散成大范围开放搜索。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteSchedulePack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackDomainSchedule,
|
||||
Content: strings.TrimSpace(`
|
||||
- 当前业务域为 schedule:只处理当前目标任务类,不重排无关内容。
|
||||
- 只有用户已明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才应停留或切入 schedule。
|
||||
- 单纯看到总节数、难度、节次偏好、禁排时段、排除星期,不足以进入 schedule;这些默认仍属于 taskclass 约束。
|
||||
- existing 只作事实参考;真正可调对象优先看 suggested。
|
||||
- 同任务类内部顺序必须保持,任何越过前驱/后继边界的移动都会被写工具拒绝。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteScheduleMutationPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackScheduleMutation,
|
||||
Content: strings.TrimSpace(`
|
||||
- mutation 包负责真正落日程写操作:place / move / swap / batch_move / unplace。
|
||||
- 写操作必须走 action=confirm;不要在 continue 里偷跑写工具。
|
||||
- 若是主动优化链路,优先在后端给出的合法候选中选择,不要自己再全窗搜索新坑位。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteQueueMicroPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackMicroQueue,
|
||||
Content: strings.TrimSpace(`
|
||||
- queue 包适合“按同一条件逐个处理一批任务”的场景,例如把所有早八任务依次挪走。
|
||||
- query_target_tasks 可结合 enqueue=true 先把候选任务入队,再用 queue_pop_head / queue_apply_head_move / queue_skip_head 顺序处理。
|
||||
- 当你需要连续处理多条相似任务时,优先走 queue,避免把整批任务细节长期堆在上下文里。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteScheduleWebPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackScheduleWeb,
|
||||
Content: strings.TrimSpace(`
|
||||
- web 包只用于补充通用学习资料或通识信息,不用于捏造个人时间、考试时间、DDL 或排程事实。
|
||||
- web_search 先粗搜,web_fetch 再抓正文;不确定时宁可不用,也不要把网页结果当成排程事实直接写入。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteTaskClassPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackDomainTaskClass,
|
||||
Content: strings.TrimSpace(`
|
||||
- taskclass 域只负责生成或修正任务类,不代表已经开始排程。
|
||||
- 学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,默认都先落在 taskclass 语义中。
|
||||
- 例:“我要复习离散数学,基础较差,大概学 8 节课,不要早上第 1-2 节和晚上第 11-12 节,周末也不想学,每节课内容你自己来”——应进入或停留 taskclass,而不是主动切 schedule,也通常不需要 ask_user。
|
||||
- 在真正调用 upsert_task_class 前,必须先做一轮写前检查;只有当参数已齐全、格式合法、业务前提已满足时,才允许输出 confirm。
|
||||
- 不要把 validation 失败当成正常试错器;validation 只用于兜底发现漏项,不应成为“先乱写一次看看后端报什么”的主流程。
|
||||
- upsert_task_class 写前最少检查项:
|
||||
1. mode=auto 时,task_class 顶层 start_date/end_date 是否已经满足。
|
||||
2. subject_type / difficulty_level / cognitive_intensity 是否齐全。
|
||||
3. difficulty_level 是否已归一到合法枚举 low/medium/high。
|
||||
4. items 是否非空,且顺序与内容是否已在当前轮生成完成。
|
||||
5. config 中已知约束字段是否已是合法格式,例如 excluded_slots 半天块索引、excluded_days_of_week 取值范围、total_slots/strategy 等。
|
||||
- 若像 items 这种内容本就由当前轮模型负责生成,就应先生成齐再写,不要把空 items 提交给 validation 去提醒你补课表内容。
|
||||
- upsert_task_class 若返回 validation.ok=false,必须先处理 validation.issues,再考虑重试或 ask_user。
|
||||
- 先区分 issue 类型:schema 字段名、字段位置、内部索引、枚举值、日期格式、工具语义映射,属于内部表示修正,应静默改参后直接重试;真正缺少用户关键信息时,才 ask_user。
|
||||
- taskclass 里的“关键信息缺失”要收窄定义:真正必须 ask_user 的,是会决定任务类真实时间边界/时间承诺的字段,而不是内部表示问题。
|
||||
- 必须 ask_user 的时间参数/条件包括:start_date、end_date、明确日期范围、明确开始日期承诺、明确完成期限;如果这些信息在当前对话、历史、记忆里都不存在,就不能由你自行拍板。
|
||||
- 当前时间锚点只能用来解析用户已经说出的相对时间;若用户没说“今天开始 / 本周内 / 两周内 / 下周前”这类时间承诺,不能因为“今天是 2026-04-27”就默认 start_date=今天,也不能默认补一个 end_date。
|
||||
- 禁排时段、排除星期、总节数、难度、内容拆分授权,不等于用户已经给出了日期范围;这些信息再完整,也不能单独推出 start_date/end_date。
|
||||
- config.excluded_slots 使用 1~6 的半天块索引;像“第1-2节”应映射到 1,“第11-12节”应映射到 6。这类换算由你内部处理,不要把底层表示解释成主要回复内容。
|
||||
- 若 validation 指出 auto 模式缺 start_date/end_date,先检查当前对话、历史、记忆里是否已有日期范围;已有就静默补齐并重试,只有确实没有时再 ask_user。
|
||||
- subject_type / difficulty_level / cognitive_intensity 是任务类语义画像必填项;优先静默推断,只有确实无法判断时再 ask_user。
|
||||
- 只要再次调用 upsert_task_class,无论是首次写入还是失败后的重试,都必须走 action=confirm。
|
||||
- 当前轮目标若是创建/修正 taskclass,应优先追求静默闭环,不要把主要篇幅花在教育用户理解工具内部约束上。
|
||||
- excluded_slots 取值应与系统节次定义一致;excluded_days_of_week 使用 1~7 表示周一到周日。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteRoughDoneMicroPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackMicroRoughDone,
|
||||
Content: strings.TrimSpace(`
|
||||
- 已有 rough_build_done:本轮以微调为主,不要把任务重新当成“未排入”再全量 place。
|
||||
- 若当前问题已经可接受,应优先收口,不要为了追求完美继续反复局部打磨。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteTaskClassRetryMicroPack() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackMicroTaskRetry,
|
||||
Content: strings.TrimSpace(`
|
||||
- 最近一次 upsert_task_class 失败时,优先围绕 validation.issues 修补。
|
||||
- 先回到“写前检查”再决定是否重试:确认 mode=auto 的日期边界、difficulty_level 合法枚举、subject_type/difficulty_level/cognitive_intensity 齐全、items 非空且已生成、config 约束字段合法。
|
||||
- 先判断 issue 是“用户关键信息缺失”还是“内部表示/工具语义修正”:前者才 ask_user,后者直接静默改参重试。
|
||||
- 如果 issue 最终落到 start_date / end_date / 日期范围 / 开始日期承诺 / 完成期限,而这些值在当前对话、历史、记忆、最近工具结果里都没有出现,就必须 ask_user;不要再拿当前时间锚点去替用户补。
|
||||
- 若用户只给了禁排时段、排除星期、总节数、难度、内容拆分授权,这仍不构成日期范围;不要把这类偏好误判成已经拿到了可写入的 start_date/end_date。
|
||||
- 如果 issue 像 difficulty_level 非法、items 为空、约束字段格式不合法,这都属于“写前本应整理好”的问题:应先在本轮静默归一/补齐/生成,再 confirm 重试,不要继续拿 validation 探路。
|
||||
- 若 issue 所需字段已在当前对话、历史、记忆或最近工具结果里出现,优先静默补齐,不要多轮解释后再写。
|
||||
- 重试 upsert_task_class 时仍然必须输出 action=confirm;不要输出 continue + tool_call。
|
||||
- 问题未解决前,不要用 done 假装收口;要么重试,要么 ask_user 补关键信息。`),
|
||||
}
|
||||
}
|
||||
|
||||
func shouldInjectExecuteDiagLoopPack(state *agentmodel.CommonState, ctx *agentmodel.ConversationContext) bool {
|
||||
if state == nil || !hasExecuteRoughBuildDone(ctx) {
|
||||
return false
|
||||
}
|
||||
if normalizeExecuteToolDomain(readExecuteActiveToolDomain(state)) != "schedule" {
|
||||
return false
|
||||
}
|
||||
activePacks := readExecuteActiveToolPacks(state)
|
||||
return hasExecutePack(activePacks, agenttools.ToolPackAnalyze) &&
|
||||
hasExecutePack(activePacks, agenttools.ToolPackMutation)
|
||||
}
|
||||
27
backend/services/agent/prompt/execute_rule_packs_health.go
Normal file
27
backend/services/agent/prompt/execute_rule_packs_health.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package agentprompt
|
||||
|
||||
import "strings"
|
||||
|
||||
func buildExecuteScheduleAnalyzePackV2() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackScheduleAnalyze,
|
||||
Content: strings.TrimSpace(`
|
||||
- analyze 包已激活:优先使用 analyze_health 判断“现在还值不值得继续主动优化”,不要把它当成全能体检表。
|
||||
- 若需要维度级细诊断(如 rhythm),再 add packs=["deep_analyze"],不要默认把所有分析都铺开。
|
||||
- 在主动优化专用模式里,analyze_health 会直接返回 decision.candidates:这些就是后端已经验证合法、并且复诊后确实变好的 move/swap 候选。
|
||||
- 一旦 decision.candidates 已经给出,下一步应直接从候选里选一个去执行;不要再自己搜索 query_target_tasks / query_available_slots。
|
||||
- 若 analyze_health 显示 should_continue_optimize=false,优先收口;不要因为“理论上还还能动”就继续局部修补。`),
|
||||
}
|
||||
}
|
||||
|
||||
func buildExecuteDiagLoopMicroPackV2() executeRulePack {
|
||||
return executeRulePack{
|
||||
Name: executeRulePackMicroDiagLoop,
|
||||
Content: strings.TrimSpace(`
|
||||
- 粗排后的主动优化允许多轮 execute,但每一轮都必须围绕“当前主问题”做局部、小范围、可解释的调整。
|
||||
- 在主动优化专用模式里,analyze_health 负责“出候选题”,你只负责在 decision.candidates 里做选择,不负责重新全窗搜点。
|
||||
- 若当前问题主要来自时间窗过紧,或所有合法候选都只是平移没有变轻,应接受局部不完美并收口。
|
||||
- 若连续两轮诊断没有明显改善,或当前 recommended_operation 已经是 close,应优先收口。
|
||||
- 主动优化优先在已有落位之间做选择:swap 优先,move 次之;不要做全窗口搜索。`),
|
||||
}
|
||||
}
|
||||
120
backend/services/agent/prompt/plan.go
Normal file
120
backend/services/agent/prompt/plan.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const planSystemPromptCore = `
|
||||
你是 SmartMate 的规划器(Planner),只负责规划,不负责执行。
|
||||
|
||||
最高优先级规则:
|
||||
1. 意图边界:只规划用户当前明确要求,禁止擅自扩展后续动作。
|
||||
2. 事实边界:禁止伪造工具调用、工具结果、外部事实和执行结论。
|
||||
3. 规划视角:先判断“最小工具闭环”再写步骤;不要先写抽象语义步骤,再让 execute 自己猜该怎么落工具。
|
||||
|
||||
规划规则:
|
||||
1. 每轮只做一次决策(continue / ask_user / plan_done)。
|
||||
2. 信息足够时优先 plan_done;信息不足时才 ask_user,且只问最小必要问题。
|
||||
3. action=plan_done 时必须返回完整 plan_steps(不是增量)。
|
||||
4. plan_steps 必须优先按“工具闭环”拆步,而不是按抽象语义拆步。
|
||||
5. 若一个目标可由单个工具闭环完成,优先生成单步计划;禁止把本可直接执行的工具动作,拆成“先分析、再设计、再确认、再执行”这类抽象多步。
|
||||
6. 每个 step 的 done_when 都应尽量贴近可观察证据,优先锚定工具回执、校验结果、查询 observation,而不是“方案完整”“分析完成”“用户应该满意”这类抽象描述。
|
||||
7. 只有单工具无法闭环,或当前步骤天然依赖上一步 observation / 用户补充信息时,才允许拆成多步。
|
||||
8. 先判断为完成目标“首个可执行闭环”最小需要的 domain / packs,再围绕这些工具写 steps,最后再产出 context_hook。context_hook 不是顺手填空,而是计划的自然推导结果。
|
||||
9. context_hook 只有一份,供 execute 首轮激活工具域使用;它应对齐“第一个可执行 step”的最小工具需求,而不是试图一次覆盖整份计划的所有后续能力。
|
||||
10. 用户若只是在描述学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,这默认仍是 taskclass 语义,不等于已经要求排进日程。
|
||||
11. 只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才允许把目标规划为 schedule;否则优先停留在 taskclass。
|
||||
12. 若意图满足批量排程识别条件,可在 plan_done 时附加 needs_rough_build 与 task_class_ids;但仅当用户明确提出排程请求时才允许这样做。
|
||||
13. 可在 plan_done 时附加 context_hook(执行阶段注入建议);若用户尚未明确要求排程,则 context_hook.domain 不得写 schedule。规划阶段禁止调用 context_tools_add/remove。
|
||||
14. 例:“我要复习离散数学,基础较差,大概学 8 节课,不要早上第 1-2 节和晚上第 11-12 节,周末也不想学,每节课内容你自己来”——这应判定为 taskclass 设计;planner 应优先理解为 taskclass 域可闭环的请求,通常单步或极少步即可,不应抽象拆成多轮。`
|
||||
|
||||
// BuildPlanSystemPrompt 返回规划阶段系统提示词。
|
||||
func BuildPlanSystemPrompt() string {
|
||||
parts := []string{
|
||||
strings.TrimSpace(planSystemPromptCore),
|
||||
BuildPlanDecisionContractText(),
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(parts, "\n\n"))
|
||||
}
|
||||
|
||||
// BuildPlanMessages 组装规划阶段的 messages。
|
||||
func BuildPlanMessages(state *agentmodel.CommonState, ctx *agentmodel.ConversationContext, userInput string) []*schema.Message {
|
||||
return buildUnifiedStageMessages(
|
||||
ctx,
|
||||
StageMessagesConfig{
|
||||
SystemPrompt: BuildPlanSystemPrompt(),
|
||||
Msg1Content: buildPlanConversationMessage(ctx),
|
||||
Msg2Content: buildPlanWorkspace(state),
|
||||
Msg3Suffix: BuildPlanUserPrompt(state, userInput),
|
||||
Msg3Role: schema.User,
|
||||
SkipBaseSystemPrompt: true,
|
||||
UseLiteToolCatalogMsg: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// BuildPlanUserPrompt 构造规划阶段的用户提示词。
|
||||
func BuildPlanUserPrompt(state *agentmodel.CommonState, userInput string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("请继续当前任务规划,只输出一组 SMARTFLOW_DECISION 决策。\n")
|
||||
sb.WriteString("请基于最近对话与规划工作区推进,不要重复已有计划内容。\n")
|
||||
sb.WriteString("请先判断最小工具闭环,再决定是否需要拆步;能单步就单步。\n")
|
||||
sb.WriteString("若需要 context_hook,请先根据第一个可执行 step 所需的最小 domain / packs 推导,再写入 hook。\n")
|
||||
sb.WriteString("禁止把本可直接落工具的动作,抽象写成“完成设计 / 确认方案 / 整理思路”之类空步骤。\n")
|
||||
sb.WriteString("输出格式与字段约束严格按 msg0 协议执行。\n")
|
||||
|
||||
trimmedInput := strings.TrimSpace(userInput)
|
||||
if trimmedInput != "" {
|
||||
sb.WriteString("\n用户本轮输入:\n")
|
||||
sb.WriteString(trimmedInput)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// BuildPlanDecisionContractText 返回规划阶段的输出协议说明。
|
||||
func BuildPlanDecisionContractText() string {
|
||||
return strings.TrimSpace(fmt.Sprintf(strings.Join([]string{
|
||||
"输出协议(唯一口径):",
|
||||
"1. 先输出:<SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>",
|
||||
"2. 再输出:给用户看的自然语言正文",
|
||||
"",
|
||||
"JSON 字段:",
|
||||
"- action:只能是 %s / %s / %s",
|
||||
"- reason:给后端和日志看的简短说明",
|
||||
"- complexity:只能是 simple / moderate / complex",
|
||||
"- plan_steps:仅当 action=%s 时允许返回,且必须是完整计划",
|
||||
"- plan_steps[].content:步骤正文,必填",
|
||||
"- plan_steps[].done_when:可选;若提供,必须尽量写成 observation / 工具回执可直接证明的完成判定",
|
||||
"- needs_rough_build:仅满足粗排识别条件时为 true,否则省略",
|
||||
"- task_class_ids:needs_rough_build=true 时必填,从上下文读取",
|
||||
"- context_hook:可选,仅用于给 execute 阶段提供注入建议",
|
||||
"- context_hook.domain:schedule / taskclass",
|
||||
"- context_hook.packs:string 数组,可选;core 固定注入,不要填入 core",
|
||||
"- context_hook.reason:可选,说明为何建议该注入",
|
||||
"",
|
||||
"注意:",
|
||||
"- JSON 中不要包含 speak 字段",
|
||||
"- 不要在 planning 阶段调用任何工具(包括 context_tools_add/remove)",
|
||||
"- 写 plan_steps 前,先判断当前目标能否由单个工具或单个紧凑工具闭环完成;若能,优先输出单步计划",
|
||||
"- 禁止把本可直接执行的工具动作,拆成抽象语义步骤,例如“先分析需求”“完成设计”“确认方案完整”",
|
||||
"- 多步计划只应用于:上一步 observation 决定下一步;或确实需要先问用户补关键事实;或目标天然跨域",
|
||||
"- context_hook 必须从 plan_steps 自然推导:优先对齐第一个可执行 step 的最小 domain / packs,不要脱离步骤单独拍脑袋生成",
|
||||
"- 若用户只给出学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,这默认属于 taskclass 设计;不要因此写 needs_rough_build=true,也不要把 context_hook.domain 设为 schedule",
|
||||
"- 只有用户明确要求\"排进日程 / 给出具体时间安排 / 现在就排一版\"时,才允许输出 needs_rough_build=true 或 context_hook.domain=schedule",
|
||||
"- 若首步本质上是任务类写入或修正,context_hook 通常应对齐 taskclass;若首步需要 schedule 查询/分析/修改,再按最小 packs 推导 schedule hook",
|
||||
"- step 的 done_when 应优先锚定:查询结果已返回、validation 已通过、写工具已成功回执、粗排标记已产生、分析结论已可直接支撑下一步",
|
||||
"- 例:\"我要复习离散数学,基础较差,大概学 8 节课,不要早上第 1-2 节和晚上第 11-12 节学习,周末也不想学,每节课内容你自己来\"——应规划为 taskclass,而不是 schedule,也通常不需要 ask_user",
|
||||
}, "\n"),
|
||||
agentmodel.PlanActionContinue,
|
||||
agentmodel.PlanActionAskUser,
|
||||
agentmodel.PlanActionDone,
|
||||
agentmodel.PlanActionDone,
|
||||
))
|
||||
}
|
||||
223
backend/services/agent/prompt/plan_context.go
Normal file
223
backend/services/agent/prompt/plan_context.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
)
|
||||
|
||||
// buildPlanConversationMessage 生成 plan 节点看到的真实对话视图。
|
||||
func buildPlanConversationMessage(ctx *agentmodel.ConversationContext) string {
|
||||
return buildConversationHistoryMessage(ctx, "规划参考对话")
|
||||
}
|
||||
|
||||
// buildPlanWorkspace 渲染 plan 节点自己的工作区。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 这里既保留“当前已有计划/任务类约束”,也显式补充“规划视角的工具摘要”;
|
||||
// 2. planner 需要先理解工具边界,才能把步骤收敛到最小闭环,而不是按抽象语义乱拆;
|
||||
// 3. 工具摘要不展开全量 schema,只提供规划真正需要的:负责什么、不负责什么、常见闭环、完成证据、域切换条件。
|
||||
func buildPlanWorkspace(state *agentmodel.CommonState) string {
|
||||
lines := []string{"规划工作区:"}
|
||||
if state == nil {
|
||||
lines = append(lines, "- 当前缺少流程状态,请主要依据最近对话与本轮输入继续规划。")
|
||||
lines = append(lines, buildPlanToolPlanningSummary())
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
if !state.HasPlan() {
|
||||
lines = append(lines, "- 当前还没有正式计划。")
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("- 已有计划:共 %d 步。", len(state.PlanSteps)))
|
||||
lines = append(lines, renderPlanCurrentStepSummary(state))
|
||||
lines = append(lines, "计划简表:")
|
||||
lines = append(lines, renderPlanStepOutline(state.PlanSteps))
|
||||
}
|
||||
|
||||
if taskClassIDs := renderPlanTaskClassIDs(state); taskClassIDs != "" {
|
||||
lines = append(lines, "- "+taskClassIDs)
|
||||
}
|
||||
if taskClassMeta := renderPlanTaskClassMeta(state); taskClassMeta != "" {
|
||||
lines = append(lines, "任务类约束:")
|
||||
lines = append(lines, taskClassMeta)
|
||||
}
|
||||
|
||||
lines = append(lines, buildPlanToolPlanningSummary())
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// renderPlanCurrentStepSummary 返回 plan 节点需要知道的当前步骤进度。
|
||||
func renderPlanCurrentStepSummary(state *agentmodel.CommonState) string {
|
||||
if state == nil || !state.HasPlan() {
|
||||
return "- 当前步骤:暂无。"
|
||||
}
|
||||
|
||||
current, total := state.PlanProgress()
|
||||
step, ok := state.CurrentPlanStep()
|
||||
if !ok {
|
||||
return fmt.Sprintf("- 当前步骤:计划共 %d 步,当前没有可继续沿用的有效步骤。", total)
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(step.Content)
|
||||
if content == "" {
|
||||
content = "(当前步骤正文为空)"
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("- 当前步骤:第 %d/%d 步,%s", current, total, content)
|
||||
if doneWhen := strings.TrimSpace(step.DoneWhen); doneWhen != "" {
|
||||
summary += fmt.Sprintf(";完成判定:%s", doneWhen)
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
// renderPlanStepOutline 将完整计划压成 plan 节点可读的简表。
|
||||
func renderPlanStepOutline(steps []agentmodel.PlanStep) string {
|
||||
if len(steps) == 0 {
|
||||
return "- 暂无。"
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(steps))
|
||||
for i, step := range steps {
|
||||
content := strings.TrimSpace(step.Content)
|
||||
if content == "" {
|
||||
content = "(步骤正文为空)"
|
||||
}
|
||||
line := fmt.Sprintf("%d. %s", i+1, content)
|
||||
if doneWhen := strings.TrimSpace(step.DoneWhen); doneWhen != "" {
|
||||
line += fmt.Sprintf(" | 完成判定:%s", doneWhen)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// renderPlanTaskClassIDs 返回批量排课场景下的 task_class_ids 简表。
|
||||
func renderPlanTaskClassIDs(state *agentmodel.CommonState) string {
|
||||
if state == nil || len(state.TaskClassIDs) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := make([]string, len(state.TaskClassIDs))
|
||||
for i, id := range state.TaskClassIDs {
|
||||
parts[i] = strconv.Itoa(id)
|
||||
}
|
||||
return fmt.Sprintf("task_class_ids=[%s]", strings.Join(parts, ", "))
|
||||
}
|
||||
|
||||
// renderPlanTaskClassMeta 返回 plan 节点真正需要看的任务类边界。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 这里只保留名称、策略、总时段、日期范围这类规划相关信息;
|
||||
// 2. 不再把所有字段原样平铺,避免工作区过胖;
|
||||
// 3. 若某项字段为空,则直接省略,不制造噪声。
|
||||
func renderPlanTaskClassMeta(state *agentmodel.CommonState) string {
|
||||
if state == nil || len(state.TaskClasses) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(state.TaskClasses))
|
||||
for _, tc := range state.TaskClasses {
|
||||
line := fmt.Sprintf("- [ID=%d] %s", tc.ID, strings.TrimSpace(tc.Name))
|
||||
if strategy := strings.TrimSpace(tc.Strategy); strategy != "" {
|
||||
line += fmt.Sprintf(";策略:%s", strategy)
|
||||
}
|
||||
if tc.TotalSlots > 0 {
|
||||
line += fmt.Sprintf(";总时段预算:%d", tc.TotalSlots)
|
||||
}
|
||||
if tc.StartDate != "" || tc.EndDate != "" {
|
||||
line += fmt.Sprintf(";日期范围:%s ~ %s", tc.StartDate, tc.EndDate)
|
||||
}
|
||||
if len(tc.ExcludedDaysOfWeek) > 0 {
|
||||
line += fmt.Sprintf(";排除星期:%v", tc.ExcludedDaysOfWeek)
|
||||
}
|
||||
if tc.SubjectType != "" || tc.DifficultyLevel != "" || tc.CognitiveIntensity != "" {
|
||||
line += fmt.Sprintf(";语义画像:%s/%s/%s",
|
||||
planSemanticValue(tc.SubjectType),
|
||||
planSemanticValue(tc.DifficultyLevel),
|
||||
planSemanticValue(tc.CognitiveIntensity),
|
||||
)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// buildPlanToolPlanningSummary 生成“规划视角的工具摘要”。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先讲 domain:让 planner 先判断目标应该停留在哪个业务域;
|
||||
// 2. 再讲 schedule packs:让 planner 知道若进入 schedule,该选最小哪组能力;
|
||||
// 3. 最后讲 hook 推导规则:因为 context_hook 只有一份,必须和“首个可执行闭环”对齐。
|
||||
func buildPlanToolPlanningSummary() string {
|
||||
sections := []string{
|
||||
"规划视角的工具摘要:",
|
||||
buildPlanToolDomainTaskClassSummary(),
|
||||
buildPlanToolDomainScheduleSummary(),
|
||||
buildPlanToolPackSummary(),
|
||||
buildPlanContextHookSummary(),
|
||||
}
|
||||
return strings.Join(sections, "\n")
|
||||
}
|
||||
|
||||
func buildPlanToolDomainTaskClassSummary() string {
|
||||
lines := []string{
|
||||
"1. taskclass 域:",
|
||||
"- 负责什么:创建 / 更新任务类,沉淀学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权、任务项结构。",
|
||||
"- 不负责什么:不给出具体日期/节次落位,不负责把任务真正排进日程。",
|
||||
"- 常见一步闭环:任务类设计或修正通常可由 taskclass 域单步闭环,核心写入动作为 upsert_task_class。",
|
||||
"- 何时停留在本域:用户仍在描述目标、偏好、约束、拆分方式,而不是要求现在排进日程。",
|
||||
"- 何时切到下一个域:只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”,或当前目标本身已变成排程执行。",
|
||||
"- done_when 证据偏好:优先锚定 upsert_task_class 成功回执、validation.ok=true、validation.issues 已清空。",
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func buildPlanToolDomainScheduleSummary() string {
|
||||
lines := []string{
|
||||
"2. schedule 域:",
|
||||
"- 负责什么:查询日程现状、粗排、具体落位、局部移动/交换、批量同规则调整、排程健康分析。",
|
||||
"- 不负责什么:不凭空补考试时间、DDL、个人空闲、外部时间事实;这类信息拿不到时应 ask_user。",
|
||||
"- 常见一步闭环:单次查询通常一个读工具即可闭环;单次移动/交换/放置通常一个写工具即可闭环;局部分析通常一个 analyze 工具即可闭环。",
|
||||
"- 何时停留在本域:用户明确要求查询、安排、调整、优化当前日程。",
|
||||
"- 何时先回 taskclass:如果用户还在定义“学什么、学多少、怎么拆、哪些时段不要学”,而不是要求立刻排程,应先停留在 taskclass。",
|
||||
"- done_when 证据偏好:优先锚定查询 observation、写工具成功回执、rough_build_done 标记、analyze observation 已能直接支撑下一步。",
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func buildPlanToolPackSummary() string {
|
||||
lines := []string{
|
||||
"3. schedule packs 选择参考:",
|
||||
"- detail_read:查看总览、查询区间、看任务详情;适合“先读事实再决定”的首步。",
|
||||
"- mutation:place / move / swap / batch_move / unplace;适合真正落日程或调日程。",
|
||||
"- analyze:analyze_health / analyze_rhythm;适合先判断是否还有优化空间、该往哪里动。",
|
||||
"- queue:适合“按同一规则逐个处理一批任务”的计划,不必把整批任务细节都堆进 steps。",
|
||||
"- web:仅补通用学习资料或通识信息;不用于补个人时间事实。",
|
||||
"- deep_analyze:适合确实需要更深一层 schedule 分析时再加,默认不要为了“看起来完整”就提前注入。",
|
||||
"- 选 pack 原则:只选首个可执行 step 真的需要的最小 packs,不要为了保险一次全带上。",
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func buildPlanContextHookSummary() string {
|
||||
lines := []string{
|
||||
"4. context_hook 推导规则:",
|
||||
"- 先确定 steps,再看第一个可执行 step 需要哪个 domain / 哪组最小 packs,最后才写 hook。",
|
||||
"- 若第一个可执行 step 本质上是任务类写入或修正,hook 通常应为 taskclass,且一般不需要 packs。",
|
||||
"- 若第一个可执行 step 是 schedule 查询,hook 应为 schedule,并优先只带 detail_read。",
|
||||
"- 若第一个可执行 step 是 schedule 分析,hook 应为 schedule,并优先带 analyze;若分析后立刻要落写,再补 mutation。",
|
||||
"- 若第一个可执行 step 是批量同规则处理,hook 应在 schedule 基础上按需加 queue。",
|
||||
"- hook 只有一份,不要求提前覆盖整份计划的所有后续能力;execute 可以在后续按计划再切域或补 packs。",
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func planSemanticValue(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return "未标注"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
106
backend/services/agent/prompt/quick_task.go
Normal file
106
backend/services/agent/prompt/quick_task.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const quickTaskSystemPrompt = `
|
||||
你是 SmartMate 的快捷任务助手。用户想记录或查看待办任务。你需要从用户消息中提取操作意图和参数。
|
||||
|
||||
你能做的操作:
|
||||
- create:记录一条新任务/提醒
|
||||
- query:查看/筛选任务列表
|
||||
|
||||
输出格式(两阶段):
|
||||
先输出一行决策标签,标签内是 JSON;标签之后换行输出给用户看的自然语言正文。
|
||||
决策标签格式:<SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>
|
||||
|
||||
JSON 字段说明:
|
||||
- action:只能是 create / query / ask
|
||||
- create 时:title 必填,deadline_at 必填,priority_group 必填,范围 1-4;estimated_sections 必填,范围 1-4,不确定默认 1;urgency_threshold_at 满足条件时填写,条件在下面
|
||||
- query 时:quadrant 可选 1-4,keyword 可选,limit 可选,deadline_after/deadline_before 可选(用于截止时间窗口筛选)
|
||||
- ask 时:question 必填
|
||||
|
||||
规则:
|
||||
1. 优先级:1=重要且紧急,2=重要不紧急,3=简单不重要,4=复杂不重要;紧急判定:截止时间距今不超过 48 小时为紧急(1),超过 48 小时为不紧急(2),无截止时间默认 3
|
||||
2. 信息不足时(如缺少 title),输出 {"action":"ask","question":"追问内容"}
|
||||
3. deadline_at 支持「明天下午3点」「下周一」「2026-04-20 18:00」等格式
|
||||
4. 未提供的可选字段直接省略,不要填 null 或空字符串
|
||||
5. JSON 中不要包含 speak 字段,给用户看的话放在 </SMARTFLOW_DECISION> 标签之后
|
||||
6. 紧急分界时间,即任务从"重要不紧急"自动轮换到"重要且紧急"的时间点;格式同 deadline_at ——当 priority_group=2 时必填,你必须根据 deadline 自动推算一个合理的紧急分界时间(通常为 deadline 前 24-48 小时),不要等用户提供;priority_group 为 1、3、4 或无截止时间时不要输出此字段
|
||||
7. query 里出现相对日期窗口(如"明天有什么事要做")时,优先输出明确边界:deadline_after="明天 00:00",deadline_before="后天 00:00",按 [after, before) 语义筛选
|
||||
|
||||
示例:
|
||||
|
||||
<SMARTFLOW_DECISION>{"action":"create","title":"明天开会","deadline_at":"明天下午3点","estimated_sections":1}</SMARTFLOW_DECISION>
|
||||
好的,我来帮你记一下。
|
||||
<SMARTFLOW_DECISION>{"action":"create","title":"下周交报告","deadline_at":"下周五 18:00","priority_group":2,"estimated_sections":2,"urgency_threshold_at":"下周四 09:00"}</SMARTFLOW_DECISION>
|
||||
好的,我也帮你记一下。
|
||||
|
||||
<SMARTFLOW_DECISION>{"action":"query","limit":5}</SMARTFLOW_DECISION>
|
||||
我帮你查一下当前的任务。
|
||||
|
||||
<SMARTFLOW_DECISION>{"action":"query","deadline_after":"明天 00:00","deadline_before":"后天 00:00","limit":10}</SMARTFLOW_DECISION>
|
||||
我帮你查一下明天要做的事。
|
||||
|
||||
<SMARTFLOW_DECISION>{"action":"ask","question":"你想记录什么呢?告诉我具体内容吧。"}</SMARTFLOW_DECISION>
|
||||
你想记录什么呢?告诉我具体内容吧。`
|
||||
|
||||
// BuildQuickTaskSystemPrompt 返回快捷任务阶段的系统提示词。
|
||||
func BuildQuickTaskSystemPrompt() string {
|
||||
return strings.TrimSpace(quickTaskSystemPrompt)
|
||||
}
|
||||
|
||||
// BuildQuickTaskMessagesSimple 组装快捷任务阶段的最简 messages(无对话历史)。
|
||||
func BuildQuickTaskMessagesSimple(userInput string) []*schema.Message {
|
||||
systemMsg := schema.SystemMessage(BuildQuickTaskSystemPrompt())
|
||||
userMsg := schema.UserMessage(buildQuickTaskUserPrompt(userInput))
|
||||
return []*schema.Message{systemMsg, userMsg}
|
||||
}
|
||||
|
||||
func buildQuickTaskUserPrompt(userInput string) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("当前时间=%s\n", time.Now().In(time.Local).Format("2006-01-02 15:04")))
|
||||
sb.WriteString("\n请从用户消息中提取操作意图和参数,严格按 SMARTFLOW_DECISION 标签格式输出。\n\n")
|
||||
|
||||
trimmedInput := strings.TrimSpace(userInput)
|
||||
if trimmedInput != "" {
|
||||
sb.WriteString("用户输入:\n")
|
||||
sb.WriteString(trimmedInput)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// BuildQuickTaskMessages 组装快捷任务阶段的完整 messages(含对话历史)。
|
||||
func BuildQuickTaskMessages(
|
||||
ctx *agentmodel.ConversationContext,
|
||||
userInput string,
|
||||
toolSchemas []agentmodel.ToolSchemaContext,
|
||||
) []*schema.Message {
|
||||
return buildUnifiedStageMessages(
|
||||
ctx,
|
||||
StageMessagesConfig{
|
||||
SystemPrompt: BuildQuickTaskSystemPrompt(),
|
||||
Msg1Content: buildChatConversationMessage(ctx),
|
||||
Msg2Content: buildQuickTaskWorkspace(toolSchemas),
|
||||
Msg3Suffix: buildQuickTaskUserPrompt(userInput),
|
||||
Msg3Role: schema.User,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func buildQuickTaskWorkspace(toolSchemas []agentmodel.ToolSchemaContext) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("可用工具:\n")
|
||||
for _, ts := range toolSchemas {
|
||||
sb.WriteString(fmt.Sprintf("- %s: %s\n 参数: %s\n", ts.Name, ts.Desc, ts.SchemaText))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
136
backend/services/agent/prompt/reasoning_summary.go
Normal file
136
backend/services/agent/prompt/reasoning_summary.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
reasoningSummaryMaxFullRunes = 6000
|
||||
reasoningSummaryMaxDeltaRunes = 1800
|
||||
)
|
||||
|
||||
// ReasoningSummaryPromptInput 描述一次“思考摘要”模型调用所需的最小输入。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只承载摘要模型需要看的文本与运行态,不绑定 stream 包的 DTO,避免 prompt 层反向依赖输出协议;
|
||||
// 2. FullReasoning 会在构造 prompt 时只保留尾部,避免长时间思考把便宜模型上下文撑爆;
|
||||
// 3. PreviousSummary 只作为连续摘要的参考,不要求模型逐字继承。
|
||||
type ReasoningSummaryPromptInput struct {
|
||||
FullReasoning string
|
||||
DeltaReasoning string
|
||||
PreviousSummary string
|
||||
CandidateSeq int
|
||||
Final bool
|
||||
DurationSeconds float64
|
||||
}
|
||||
|
||||
type reasoningSummaryPromptPayload struct {
|
||||
CandidateSeq int `json:"candidate_seq"`
|
||||
Final bool `json:"final"`
|
||||
DurationSeconds float64 `json:"duration_seconds"`
|
||||
PreviousSummary string `json:"previous_summary,omitempty"`
|
||||
RecentReasoning string `json:"recent_reasoning,omitempty"`
|
||||
DeltaReasoning string `json:"delta_reasoning,omitempty"`
|
||||
SourceTextRunes int `json:"source_text_runes,omitempty"`
|
||||
MaxDetailSummaryRunes int `json:"max_detail_summary_runes,omitempty"`
|
||||
}
|
||||
|
||||
// BuildReasoningSummaryMessages 构造思考摘要模型调用的 messages。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. system prompt 明确“只做用户可见摘要”,禁止复述原始思考链和内部推理细节;
|
||||
// 2. user prompt 使用 JSON 承载输入,便于后续扩展字段且减少模型误读;
|
||||
// 3. 长文本只保留尾部窗口,保证异步摘要请求稳定、便宜、可控。
|
||||
func BuildReasoningSummaryMessages(input ReasoningSummaryPromptInput) []*schema.Message {
|
||||
recentReasoning := trimRunesFromEnd(input.FullReasoning, reasoningSummaryMaxFullRunes)
|
||||
deltaReasoning := trimRunesFromEnd(input.DeltaReasoning, reasoningSummaryMaxDeltaRunes)
|
||||
payload := reasoningSummaryPromptPayload{
|
||||
CandidateSeq: input.CandidateSeq,
|
||||
Final: input.Final,
|
||||
DurationSeconds: input.DurationSeconds,
|
||||
PreviousSummary: strings.TrimSpace(input.PreviousSummary),
|
||||
RecentReasoning: recentReasoning,
|
||||
DeltaReasoning: deltaReasoning,
|
||||
SourceTextRunes: reasoningSummarySourceRunes(recentReasoning, deltaReasoning),
|
||||
MaxDetailSummaryRunes: ReasoningSummaryDetailRuneLimit(input.FullReasoning, input.DeltaReasoning),
|
||||
}
|
||||
|
||||
raw, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
raw = []byte(fmt.Sprintf(`{"recent_reasoning":%q}`, trimRunesFromEnd(input.FullReasoning, reasoningSummaryMaxFullRunes)))
|
||||
}
|
||||
|
||||
return []*schema.Message{
|
||||
schema.SystemMessage(buildReasoningSummarySystemPrompt()),
|
||||
schema.UserMessage("请基于 delta_reasoning 生成本轮新增的用户可见阶段摘要;recent_reasoning 仅作上下文,previous_summary 仅作去重参考。\n输入:\n" + string(raw)),
|
||||
}
|
||||
}
|
||||
|
||||
func buildReasoningSummarySystemPrompt() string {
|
||||
return strings.TrimSpace(`你是 SmartMate 的“思考摘要器”。你的任务是把模型内部 reasoning 整理成用户可见的进度摘要。
|
||||
|
||||
输出必须是严格 JSON 对象:
|
||||
{
|
||||
"short_summary": "8到18个汉字的短摘要",
|
||||
"detail_summary": "不超过 max_detail_summary_runes 个字的展开摘要"
|
||||
}
|
||||
|
||||
字段语义:
|
||||
- previous_summary:上一条已经展示给用户的摘要,只用于判断哪些内容已经说过。
|
||||
- delta_reasoning:本轮新增 reasoning,是生成 detail_summary 的主要依据。
|
||||
- recent_reasoning:全量尾部上下文,只用于补齐题目、变量名、阶段背景,不要按它重写一遍完整摘要。
|
||||
|
||||
规则:
|
||||
1. 不输出 markdown,不输出代码块,不解释 JSON 以外的内容。
|
||||
2. 摘要要像“阶段更新”,不是流水账;优先写新增结论、阶段变化、卡点、修正、下一步动作。
|
||||
3. detail_summary 以 delta_reasoning 为主;previous_summary 已覆盖的信息不要大段重复,除非本轮对它有修正或推进。
|
||||
4. short_summary 用 8 到 18 个汉字,偏结果或动作短语,例如“补齐边界条件”“转入代码实现”“优化滚动数组”。
|
||||
5. detail_summary 用自然的一到两句话表达,优先以具体对象、动作或结果开头,不要把“正在”“当前”“已确定”“已完成”作为默认句首模板;若 previous_summary 已使用类似开头,本轮必须换一种表达。
|
||||
6. final=false 时不要用“已完成”概括整体任务;只有 delta_reasoning 明确完成某个局部步骤时,才可描述该局部已经完成。
|
||||
7. detail_summary 字数必须小于等于 max_detail_summary_runes;不需要凑满上限,信息密度优先。
|
||||
8. 不暴露原始思考链、隐含假设链、逐步演算,只保留用户可见的进展。
|
||||
9. 若本轮没有实质新增信息,输出保守但不重复的摘要,例如“沿用上一轮判断,暂无新的可展示进展。”
|
||||
10. final=true 时,用完成态语气,说明思考已经收拢到下一步答复或动作。`)
|
||||
}
|
||||
|
||||
// ReasoningSummaryDetailRuneLimit 返回 detail_summary 的最大字数。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 与 BuildReasoningSummaryMessages 使用同一套输入窗口,避免 prompt 提示和服务端兜底口径不一致;
|
||||
// 2. 上限取“提供给摘要模型的主要文本段”的一半,并向上取整,适配极短文本;
|
||||
// 3. 返回 0 表示没有有效输入文本,调用方不应做硬裁剪。
|
||||
func ReasoningSummaryDetailRuneLimit(fullReasoning, deltaReasoning string) int {
|
||||
recentReasoning := trimRunesFromEnd(fullReasoning, reasoningSummaryMaxFullRunes)
|
||||
delta := trimRunesFromEnd(deltaReasoning, reasoningSummaryMaxDeltaRunes)
|
||||
sourceRunes := reasoningSummarySourceRunes(recentReasoning, delta)
|
||||
if sourceRunes <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (sourceRunes + 1) / 2
|
||||
}
|
||||
|
||||
func reasoningSummarySourceRunes(recentReasoning, deltaReasoning string) int {
|
||||
recentReasoning = strings.TrimSpace(recentReasoning)
|
||||
if recentReasoning != "" {
|
||||
return utf8.RuneCountInString(recentReasoning)
|
||||
}
|
||||
return utf8.RuneCountInString(strings.TrimSpace(deltaReasoning))
|
||||
}
|
||||
|
||||
func trimRunesFromEnd(text string, maxRunes int) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" || maxRunes <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
runes := []rune(text)
|
||||
if len(runes) <= maxRunes {
|
||||
return text
|
||||
}
|
||||
return string(runes[len(runes)-maxRunes:])
|
||||
}
|
||||
11
backend/services/agent/prompt/system.go
Normal file
11
backend/services/agent/prompt/system.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package agentprompt
|
||||
|
||||
const (
|
||||
// SystemPrompt 全局系统人设:定义 SmartMate 的基本调性
|
||||
SystemPrompt = `你叫 SmartMate,是时伴(SmartMate)的中文 AI 排程伙伴,面向大学生提供陪伴式日程管理与日常协助。
|
||||
你擅长课表与任务安排、任务管理、学习规划和随口记,也可以正常回答日常问答、生活建议、信息整理、分析讨论等非排程问题。
|
||||
你的目标是像一个越用越懂用户的伙伴一样,结合历史对话、长期记忆和当前上下文,给出贴心、清晰、可信的帮助。
|
||||
你的回复应当专业、自然、有陪伴感,偶尔可以带一点轻松幽默。
|
||||
如果用户的问题与日程无关,不要因为"不属于排程"就拒绝、回避或强行转到任务安排;只要不需要工具且你有把握,就直接回答。
|
||||
重要约束:你无法直接写入数据库。除非系统明确告知"任务已落库成功",否则禁止使用"已安排/已记录/已帮你记下"等完成态表述。`
|
||||
)
|
||||
273
backend/services/agent/prompt/unified_context.go
Normal file
273
backend/services/agent/prompt/unified_context.go
Normal file
@@ -0,0 +1,273 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// ConversationTurn 表示对话历史中的一轮自然语言交互。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只承载 user 与 assistant speak,不承载 tool_call 和 tool observation;
|
||||
// 2. 供 chat / plan / deliver 等节点复用,避免各节点重复写一套提取逻辑;
|
||||
// 3. 不负责裁剪长度,长度预算统一交给压缩层处理。
|
||||
type ConversationTurn struct {
|
||||
Role string
|
||||
Content string
|
||||
}
|
||||
|
||||
// StageMessagesConfig 描述统一四段式骨架下,各节点自行提供的内容块。
|
||||
//
|
||||
// 设计目标:
|
||||
// 1. 统一层只负责“四条消息怎么拼”,不再替节点决定“每条消息里该放什么”;
|
||||
// 2. Msg1 / Msg2 / Msg3Prefix / Msg3Suffix 都由节点自己渲染,避免 chat / plan / deliver 继续套 execute 的内容模板;
|
||||
// 3. memory_context 仍由统一层单入口注入到 msg3,避免多处重复注入。
|
||||
type StageMessagesConfig struct {
|
||||
// SystemPrompt 是节点自己的系统提示词。
|
||||
SystemPrompt string
|
||||
|
||||
// Msg1Content 是第 2 条 assistant 消息,通常放“节点想看的历史视图”。
|
||||
Msg1Content string
|
||||
|
||||
// Msg2Content 是第 3 条 assistant 消息,通常放“节点自己的工作区/补充约束”。
|
||||
Msg2Content string
|
||||
|
||||
// Msg3Prefix 是第 4 条消息中位于 memory_context 之前的内容。
|
||||
// 常见放法:阶段状态、规划工作区摘要、交付收口约束等。
|
||||
Msg3Prefix string
|
||||
|
||||
// Msg3Suffix 是第 4 条消息中位于 memory_context 之后的内容。
|
||||
// 对 user-role 节点来说,这里通常放最终用户指令,保证“用户输入收尾”。
|
||||
Msg3Suffix string
|
||||
|
||||
// Msg3Role 指定第 4 条消息的角色。
|
||||
// Execute 继续使用 system,其余节点一般使用 user。
|
||||
Msg3Role schema.RoleType
|
||||
|
||||
// SkipBaseSystemPrompt 为 true 时,msg0 只使用节点自己的 SystemPrompt,
|
||||
// 不再拼接 ConversationContext.SystemPrompt。
|
||||
SkipBaseSystemPrompt bool
|
||||
|
||||
// UseLiteToolCatalogMsg 为 true 时,msg0 工具目录采用轻量模式(仅名称与职责)。
|
||||
UseLiteToolCatalogMsg bool
|
||||
}
|
||||
|
||||
// buildUnifiedStageMessages 组装统一 4 段式消息骨架。
|
||||
//
|
||||
// 固定布局:
|
||||
// 1. msg0(system):系统规则 + 阶段规则 + 工具简表;
|
||||
// 2. msg1(assistant):节点自定义的历史视图;
|
||||
// 3. msg2(assistant):节点自定义的工作区;
|
||||
// 4. msg3(user/system):节点自定义前后缀 + 统一 memory_context。
|
||||
func buildUnifiedStageMessages(
|
||||
ctx *agentmodel.ConversationContext,
|
||||
config StageMessagesConfig,
|
||||
) []*schema.Message {
|
||||
msg0 := buildUnifiedMsg0(config.SystemPrompt, ctx, config.SkipBaseSystemPrompt, config.UseLiteToolCatalogMsg)
|
||||
msg1 := buildUnifiedMsg1(config.Msg1Content)
|
||||
msg2 := buildUnifiedMsg2(config.Msg2Content)
|
||||
msg3 := buildUnifiedMsg3(ctx, config)
|
||||
|
||||
return []*schema.Message{
|
||||
schema.SystemMessage(msg0),
|
||||
{Role: schema.Assistant, Content: msg1},
|
||||
{Role: schema.Assistant, Content: msg2},
|
||||
buildUnifiedMsg3Message(msg3, config.Msg3Role),
|
||||
}
|
||||
}
|
||||
|
||||
// buildUnifiedMsg3Message 根据配置决定第 4 条消息的角色。
|
||||
func buildUnifiedMsg3Message(content string, role schema.RoleType) *schema.Message {
|
||||
if role == schema.User {
|
||||
return schema.UserMessage(content)
|
||||
}
|
||||
return schema.SystemMessage(content)
|
||||
}
|
||||
|
||||
// buildUnifiedMsg0 合并系统提示 + 工具简表,生成 msg0。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先合并基础系统提示与节点系统提示,保证模型身份稳定;
|
||||
// 2. 若当前节点注入了工具 schema,则附加紧凑工具目录;
|
||||
// 3. 若两部分都为空,则回退到最小兜底提示,避免出现空消息。
|
||||
func buildUnifiedMsg0(stageSystemPrompt string, ctx *agentmodel.ConversationContext, skipBaseSystemPrompt bool, useLiteToolCatalog bool) string {
|
||||
base := ""
|
||||
if skipBaseSystemPrompt {
|
||||
base = strings.TrimSpace(stageSystemPrompt)
|
||||
} else {
|
||||
base = strings.TrimSpace(mergeSystemPrompts(ctx, stageSystemPrompt))
|
||||
}
|
||||
if base == "" {
|
||||
base = "你是 SmartMate 助手,请继续当前阶段。"
|
||||
}
|
||||
|
||||
toolCatalog := renderExecuteToolCatalogCompact(ctx, nil)
|
||||
if useLiteToolCatalog {
|
||||
toolCatalog = renderUnifiedToolCatalogLite(ctx)
|
||||
}
|
||||
if toolCatalog == "" {
|
||||
return base
|
||||
}
|
||||
return base + "\n\n" + toolCatalog
|
||||
}
|
||||
|
||||
// renderUnifiedToolCatalogLite 渲染统一阶段可用工具的轻量目录。
|
||||
//
|
||||
// 1. 只展示工具名和一句话职责,避免把 execute 的参数/返回示例污染到 plan/chat/deliver。
|
||||
// 2. 目录信息仅用于“能力边界感知”,不承担具体参数指导。
|
||||
// 3. 当工具数量过多时保留前若干项并给出省略提示,控制 msg0 体积。
|
||||
func renderUnifiedToolCatalogLite(ctx *agentmodel.ConversationContext) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
schemas := ctx.ToolSchemasSnapshot()
|
||||
if len(schemas) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
const maxItems = 18
|
||||
lines := []string{"当前可用工具(轻量目录):"}
|
||||
added := 0
|
||||
|
||||
for _, item := range schemas {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
desc := strings.TrimSpace(item.Desc)
|
||||
if desc == "" {
|
||||
lines = append(lines, fmt.Sprintf("- %s", name))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("- %s:%s", name, desc))
|
||||
}
|
||||
added++
|
||||
if added >= maxItems {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if added == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(schemas) > added {
|
||||
lines = append(lines, fmt.Sprintf("- 其余 %d 个工具已省略(按需再看)。", len(schemas)-added))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// buildUnifiedMsg1 返回节点自行提供的历史视图。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 统一层不再内置 execute 风格的 ReAct 摘要;
|
||||
// 2. 节点若未传入内容,则回退到最小占位,保证四段结构稳定;
|
||||
// 3. 压缩层仍会统一统计和压缩这条消息。
|
||||
func buildUnifiedMsg1(content string) string {
|
||||
content = strings.TrimSpace(content)
|
||||
if content != "" {
|
||||
return content
|
||||
}
|
||||
return "历史上下文:暂无。"
|
||||
}
|
||||
|
||||
// buildUnifiedMsg2 返回节点自行提供的工作区。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 非 execute 节点也允许有自己的 msg2,不再被统一层硬塞“暂无”语义;
|
||||
// 2. 若节点暂时没有额外工作区,则回退到最小占位,保证结构稳定。
|
||||
func buildUnifiedMsg2(content string) string {
|
||||
content = strings.TrimSpace(content)
|
||||
if content != "" {
|
||||
return content
|
||||
}
|
||||
return "阶段工作区:暂无。"
|
||||
}
|
||||
|
||||
// buildUnifiedMsg3 统一拼装 msg3:前缀 + memory_context + 后缀。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 前缀由节点决定,适合放轻量状态或阶段约束;
|
||||
// 2. memory_context 只在这里注入一次,避免 pinned block 多入口重复出现;
|
||||
// 3. 后缀由节点决定。对于 user-role 节点,通常把最终用户指令放在这里,保证消息末尾仍是用户输入。
|
||||
func buildUnifiedMsg3(ctx *agentmodel.ConversationContext, config StageMessagesConfig) string {
|
||||
var sections []string
|
||||
|
||||
if prefix := strings.TrimSpace(config.Msg3Prefix); prefix != "" {
|
||||
sections = append(sections, prefix)
|
||||
}
|
||||
if memoryText := renderUnifiedMemoryContext(ctx); memoryText != "" {
|
||||
sections = append(sections, "相关记忆(仅在确有帮助时参考,不要机械复述):\n"+memoryText)
|
||||
}
|
||||
if suffix := strings.TrimSpace(config.Msg3Suffix); suffix != "" {
|
||||
sections = append(sections, suffix)
|
||||
}
|
||||
|
||||
if len(sections) == 0 {
|
||||
return "请继续当前阶段。"
|
||||
}
|
||||
return strings.Join(sections, "\n\n")
|
||||
}
|
||||
|
||||
// renderUnifiedMemoryContext 提取需要补充到 msg3 的记忆文本。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 只消费 memory_context,避免把 execution_context / current_step 等阶段专属块混回 prompt;
|
||||
// 2. block 不存在或正文为空时直接返回空串;
|
||||
// 3. 这里只读取 agent/sv 已经产出的最终文本,不在这里重新拼装记忆。
|
||||
func renderUnifiedMemoryContext(ctx *agentmodel.ConversationContext) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
block, ok := ctx.PinnedBlockByKey("memory_context")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
content := strings.TrimSpace(block.Content)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
// CollectConversationTurns 从历史消息中提取 user + assistant speak 对话流。
|
||||
//
|
||||
// 提取规则:
|
||||
// 1. 只保留 user 消息(排除 correction prompt)和 assistant 纯文本消息;
|
||||
// 2. assistant tool_call 消息与 tool observation 消息不纳入“真实对话”;
|
||||
// 3. 返回顺序保持与原始 history 一致。
|
||||
func CollectConversationTurns(history []*schema.Message) []ConversationTurn {
|
||||
if len(history) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
turns := make([]ConversationTurn, 0, len(history))
|
||||
for _, msg := range history {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(msg.Content)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
switch msg.Role {
|
||||
case schema.User:
|
||||
// 1. 跳过后端注入的 correction prompt,避免把纠错文案误判为用户真实意图。
|
||||
if isExecuteCorrectionPrompt(msg) {
|
||||
continue
|
||||
}
|
||||
turns = append(turns, ConversationTurn{Role: "user", Content: text})
|
||||
case schema.Assistant:
|
||||
// 2. 跳过工具调用消息,只保留真正面向用户的 speak/答复。
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
continue
|
||||
}
|
||||
turns = append(turns, ConversationTurn{Role: "assistant", Content: text})
|
||||
}
|
||||
}
|
||||
|
||||
return turns
|
||||
}
|
||||
Reference in New Issue
Block a user