Files
WeiCHEN a5ea8197ab fix(coordinator): force Tavily search for weather/realtime dialogue answers
Anchor weather and other realtime questions on runner /v1/search results (or an explicit refuse constraint) so the model cannot invent dates or temperatures from training memory.
2026-09-23 14:51:09 +08:00

283 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package coordinator
import (
"fmt"
"strings"
"time"
"chat-agent-system/internal/ctxmgr"
)
// dialogueCST 与 ctxmgr day_key 约定一致:固定 UTC+8,不依赖容器 tzdata。
var dialogueCST = time.FixedZone("CST", 8*3600)
var weekdayCN = map[time.Weekday]string{
time.Sunday: "周日",
time.Monday: "周一",
time.Tuesday: "周二",
time.Wednesday: "周三",
time.Thursday: "周四",
time.Friday: "周五",
time.Saturday: "周六",
}
// MemoryComposer 分层记忆编排器:遵循严格的十段式模板结构自上而下组装对话 Prompt
type MemoryComposer struct {
MaxWindowTurns int
// NowFn 可注入时钟;nil 时使用 time.Now(测试可固定)。
NowFn func() time.Time
}
// NewMemoryComposer 创建分层记忆编排器
func NewMemoryComposer() *MemoryComposer {
return &MemoryComposer{
MaxWindowTurns: dialogueHistoryMax,
NowFn: time.Now,
}
}
func (mc *MemoryComposer) now() time.Time {
if mc != nil && mc.NowFn != nil {
return mc.NowFn()
}
return time.Now()
}
// formatDialogueClock 渲染提示词用当前时间行(Asia/Shanghai / UTC+8)。
func formatDialogueClock(t time.Time) string {
local := t.In(dialogueCST)
wd := weekdayCN[local.Weekday()]
return fmt.Sprintf("%s (%s) %s (UTC+8)",
local.Format("2006-01-02"), wd, local.Format("15:04"))
}
// ComposeDialoguePrompt 依据结构化规范编排完整提示词
// 布局结构 (自上而下):
// 1. 【工作记忆】 task_state (DST) + PendingExpectation 挂起建议 + InteractiveCard 状态
// 2. 【项目基准与协同上下文】 项目/用户/器械/阶段/待办/黑板事实(最高优先级声明)
// 3. 【早期对话摘要】 压缩记忆(仅长会话出现)
// 4. 【近期对话】 短期记忆(滑动窗口近 M 轮,逐轮编号 + 范围标注)
// 5. 【相关历史片段】 按需语义检索 TopK≤3
// 6. 【参考知识】 法规 KB 命中(带 CiteKey 引用键)/ 标杆案例参考
// 7. 【用户偏好画像】 PersonalizedGuidance(沟通风格与重点关注)
// 8. 【本轮用户】 原文 + (结合上下文理解完整诉求: …)双呈现
// 9. 【答复要求】 优先级裁决、引用规范、隐私条款与输出格式
func (mc *MemoryComposer) ComposeDialoguePrompt(userInput string, userCtx *UserProjectContext) string {
if userCtx == nil {
return fmt.Sprintf("【本轮用户】\n%s\n\n【答复要求】\n请结合上下文,以温和助理口吻作答,并写入 reply.md。", userInput)
}
var sb strings.Builder
// -------------------------------------------------------------
// 1. 【工作记忆】 (~200 字)
// -------------------------------------------------------------
var wmBuilder strings.Builder
hasWorkingMemory := false
if userCtx.TaskState != nil {
if tsSnippet := userCtx.TaskState.RenderPromptSnippet(); tsSnippet != "" {
wmBuilder.WriteString(tsSnippet)
wmBuilder.WriteString("\n")
hasWorkingMemory = true
}
}
if userCtx.PendingExpectation != nil && userCtx.PendingExpectation.ContextSnippet != "" {
wmBuilder.WriteString(fmt.Sprintf("- 上一轮挂起建议摘要: %s\n", userCtx.PendingExpectation.ContextSnippet))
hasWorkingMemory = true
}
if userCtx.InteractiveCard != nil && userCtx.InteractiveCard.CurrentStatus == "active" {
wmBuilder.WriteString(fmt.Sprintf("- 当前待响应交互卡片: %s (剩余%d轮)\n", userCtx.InteractiveCard.Title, userCtx.InteractiveCard.TTLTurns))
hasWorkingMemory = true
}
if hasWorkingMemory {
sb.WriteString("【工作记忆】\n")
sb.WriteString(wmBuilder.String())
sb.WriteString("\n")
}
// -------------------------------------------------------------
// 2. 【项目基准与协同上下文】 项目基准信息、待办事项、黑板事实 (声明最高优先级)
// -------------------------------------------------------------
sb.WriteString("【项目基准与协同上下文】\n")
sb.WriteString(fmt.Sprintf("- 当前时间: %s\n", formatDialogueClock(mc.now())))
sb.WriteString(fmt.Sprintf("- 当前项目: %s (ID: %s)\n", userCtx.Project.Name, userCtx.Project.ID))
sb.WriteString(fmt.Sprintf("- 阶段: %s | 当前工作流: %s\n", userCtx.Project.CurrentStage, userCtx.Project.CurrentWorkflow))
if userCtx.Project.DeviceType != "" || userCtx.Project.TargetMarket != "" {
sb.WriteString(fmt.Sprintf("- 器械与市场: %s | %s\n", userCtx.Project.DeviceType, userCtx.Project.TargetMarket))
}
if userCtx.User.Name != "" {
sb.WriteString(fmt.Sprintf("- 当前协同用户: %s (角色: %s, 项目角色: %s)\n", userCtx.User.Name, userCtx.User.Role, userCtx.User.ProjectRole))
}
if len(userCtx.Pending.ConfirmModifications) > 0 ||
len(userCtx.Pending.PendingReviews) > 0 ||
len(userCtx.Pending.PendingApprovals) > 0 ||
len(userCtx.Pending.MyDraftsInProgress) > 0 {
sb.WriteString("- 用户侧待办线索:\n")
for _, x := range userCtx.Pending.ConfirmModifications {
sb.WriteString(fmt.Sprintf(" - 待确认修改: %s\n", x))
}
for _, x := range userCtx.Pending.PendingReviews {
sb.WriteString(fmt.Sprintf(" - 待审核: %s\n", x))
}
for _, x := range userCtx.Pending.PendingApprovals {
sb.WriteString(fmt.Sprintf(" - 待批准: %s\n", x))
}
for _, x := range userCtx.Pending.MyDraftsInProgress {
sb.WriteString(fmt.Sprintf(" - 编制中: %s\n", x))
}
}
if len(userCtx.BlackboardFacts) > 0 {
sb.WriteString("- 项目黑板核心事实(最高优先级,与早期摘要或历史片段冲突时以此为准):\n")
n := 0
for k, v := range userCtx.BlackboardFacts {
sb.WriteString(fmt.Sprintf(" - %s = %s\n", k, v))
n++
if n >= 12 {
break
}
}
}
sb.WriteString("\n")
// -------------------------------------------------------------
// 3. 【早期对话摘要】 压缩记忆 (~300 字)
// -------------------------------------------------------------
if s := strings.TrimSpace(userCtx.DialogueSummary); s != "" {
sb.WriteString(s)
if !strings.HasSuffix(s, "\n") {
sb.WriteString("\n")
}
sb.WriteString("\n")
}
// -------------------------------------------------------------
// 4. 【近期对话】 滑动窗口近 M 轮 (~3000 字,含范围标注)
// -------------------------------------------------------------
if len(userCtx.RecentDialogue) > 0 {
totalTurns := len(userCtx.RecentDialogue)
turns := userCtx.RecentDialogue
startIdx := 1
if totalTurns > mc.MaxWindowTurns {
turns = turns[totalTurns-mc.MaxWindowTurns:]
startIdx = totalTurns - mc.MaxWindowTurns + 1
sb.WriteString(fmt.Sprintf("【近期对话】(第 %d~%d 轮,更早对话已压缩至摘要)\n", startIdx, totalTurns))
} else {
sb.WriteString("【近期对话】\n")
}
for i, t := range turns {
roleName := "用户"
if t.Role == "assistant" {
roleName = "助手"
} else if t.Role == "system" {
roleName = "系统"
}
sb.WriteString(fmt.Sprintf("[%d] %s: %s\n", startIdx+i, roleName, t.Content))
}
sb.WriteString("\n")
}
// -------------------------------------------------------------
// 5. 【相关历史片段】 语义检索切片 TopK≤3 (~600 字)
// -------------------------------------------------------------
recalledText := strings.TrimSpace(userCtx.RecalledMemorySnippet)
if recalledText == "" && len(userCtx.RecalledMemories) > 0 {
recalledText = strings.TrimSpace(ctxmgr.FormatRecalledMemorySnippet(userCtx.RecalledMemories, userCtx.SnapshotID))
}
if recalledText != "" {
sb.WriteString(recalledText)
if !strings.HasSuffix(recalledText, "\n") {
sb.WriteString("\n")
}
sb.WriteString("\n")
}
// -------------------------------------------------------------
// 6. 【参考知识】 法规 KB 命中(带 CiteKey)/ 往期标杆案例
// -------------------------------------------------------------
hasKnowledge := len(userCtx.RegulationHits) > 0 ||
(userCtx.LatestAdvisorBrief != nil && len(userCtx.LatestAdvisorBrief.RecommendedCases) > 0)
if hasKnowledge {
sb.WriteString("【参考知识】(权威法规与标准依据,涉及结论时请沿用〔引用键〕引用)\n")
for _, h := range userCtx.RegulationHits {
cite := h.CiteKey
if cite == "" {
cite = fmt.Sprintf("〔%s〕", h.DocCode)
}
title := h.Title
if title == "" {
title = h.DocCode
}
sb.WriteString(fmt.Sprintf("• %s %s\n", cite, title))
if h.HeadingPath != "" {
sb.WriteString(fmt.Sprintf(" - 条款章节: %s\n", h.HeadingPath))
}
if h.Content != "" {
cText := strings.TrimSpace(h.Content)
if len([]rune(cText)) > 150 {
cText = string([]rune(cText)[:150]) + "..."
}
sb.WriteString(fmt.Sprintf(" - 条款要点: %s\n", cText))
}
}
if userCtx.LatestAdvisorBrief != nil && len(userCtx.LatestAdvisorBrief.RecommendedCases) > 0 {
for _, c := range userCtx.LatestAdvisorBrief.RecommendedCases {
sb.WriteString(fmt.Sprintf("• 《%s》(%s): %s\n", c.ArtifactTitle, c.TaskKey, c.SummarySnippet))
}
}
sb.WriteString("\n")
}
// -------------------------------------------------------------
// 6b. 【实时检索结果】 天气/新闻等权威实时数据(强制锚定)
// -------------------------------------------------------------
if snip := strings.TrimSpace(userCtx.RealtimeSnippet); snip != "" {
sb.WriteString("【实时检索结果】(权威实时数据,回答天气/新闻等只准以此为准)\n")
sb.WriteString(snip)
if !strings.HasSuffix(snip, "\n") {
sb.WriteString("\n")
}
sb.WriteString("\n")
}
// -------------------------------------------------------------
// 7. 【用户偏好画像】 PersonalizedGuidance (~300 字)
// -------------------------------------------------------------
if g := strings.TrimSpace(userCtx.PersonalizedGuidance); g != "" {
sb.WriteString(g)
if !strings.HasSuffix(g, "\n") {
sb.WriteString("\n")
}
sb.WriteString("\n")
}
// -------------------------------------------------------------
// 8. 【本轮用户】
// -------------------------------------------------------------
sb.WriteString("【本轮用户】\n")
if userCtx.RewrittenQuery != "" && userCtx.RewrittenQuery != userInput {
sb.WriteString(fmt.Sprintf("用户原话: %s\n(结合上下文理解完整诉求: %s)", userInput, userCtx.RewrittenQuery))
} else {
sb.WriteString(userInput)
}
sb.WriteString("\n\n")
// -------------------------------------------------------------
// 9. 【答复要求】
// -------------------------------------------------------------
sb.WriteString("【答复要求】\n")
sb.WriteString("1. 优先级裁决:项目黑板核心事实 > 本轮用户输入 > 近期对话 > 早期摘要 > 检索片段。\n")
sb.WriteString("2. 事实与引用:涉及法规条款与标准时,优先依据【参考知识】与【项目基准与协同上下文】并沿用〔引用键〕格式;无把握时明确提示依据不足,严禁生造标准号。\n")
sb.WriteString("3. 隐私与跨用户边界:其他成员姓名与私人习惯仅限理解语境,禁止外泄复述。\n")
sb.WriteString("4. 答复目标:请结合上下文,以温和严谨的「项目助手」口吻作答,并写入 reply.md。\n")
if userCtx.RealtimeUnavailable {
sb.WriteString("5. 本轮实时检索失败:必须明确告知无法获取实时数据(建议查看天气 App),严禁编造任何具体天气/新闻数值。\n")
} else if strings.TrimSpace(userCtx.RealtimeSnippet) != "" {
sb.WriteString("5. 实时数据:涉及天气/新闻等时效问题,必须严格依据【实时检索结果】作答,不得使用训练记忆中的过期数值。\n")
}
return sb.String()
}