fix(agent): scope Task 2 to SSE phases/errors without dialogue_llm leak
- untrack internal/coordinator/dialogue_llm.go (kept on disk untracked as local WIP for a future dialogue-LLM commit with its own tests; it depends on engine.go fields (dialogueBackend, ReplySource) not yet committed)
- slim internal/chat/session.go back to Task 2 scope relative to bfa6889: keep EmitErrorInfo(stream.ClassifyError(...)) for intent+coordination errors and the dialogue_generating EmitPhase before ProcessIntent; drop sync.Mutex/mu, ChatResponse.ReplySource, preference/RecentDialogue injection block, and collectRecentDialogue helper (unrelated WIP for the dialogue-LLM feature)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,7 +3,6 @@ package chat
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/coordinator"
|
||||
@@ -28,7 +27,6 @@ type Message struct {
|
||||
// ChatResponse 会话单轮响应
|
||||
type ChatResponse struct {
|
||||
ReplyText string `json:"reply_text"`
|
||||
ReplySource string `json:"reply_source,omitempty"` // pi-agent | template
|
||||
Branch intent.ResolutionBranch `json:"branch"`
|
||||
IntentResult *intent.IntentRecognitionResult `json:"intent_result"`
|
||||
CoordinationResult *coordinator.CoordinationResult `json:"coordination_result"`
|
||||
@@ -48,8 +46,6 @@ type Session struct {
|
||||
CtxMgr *ctxmgr.ContextManager `json:"-"`
|
||||
Messages []Message `json:"messages"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
mu sync.Mutex `json:"-"` // 同 session 消息串行化,保障并发正确性
|
||||
}
|
||||
|
||||
// NewSession 创建新对话会话
|
||||
@@ -90,9 +86,6 @@ func (s *Session) SendMessageStream(ctx context.Context, userInput string, liste
|
||||
}
|
||||
|
||||
func (s *Session) sendMessageInternal(ctx context.Context, userInput string, streamer *stream.Streamer) (*ChatResponse, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
start := time.Now()
|
||||
if streamer != nil {
|
||||
streamer.EmitPhase("intent_recognizing", "正在研判您的意图与项目上下文...", "coordinator", 0, 0, 0)
|
||||
@@ -107,8 +100,7 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
|
||||
s.Messages = append(s.Messages, userMsg)
|
||||
|
||||
if s.CtxMgr != nil {
|
||||
userMeta := map[string]any{"project_id": s.ProjectID}
|
||||
_ = s.CtxMgr.RecordUserMessage(ctx, s.ID, s.UserID, "user", userInput, "", "", userMeta)
|
||||
_ = s.CtxMgr.RecordUserMessage(ctx, s.ID, s.UserID, "user", userInput, "", "", nil)
|
||||
}
|
||||
|
||||
// 1. 意图识别 (L1 对话层,带非阻塞桌角备忘快照)
|
||||
@@ -134,23 +126,7 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
|
||||
)
|
||||
}
|
||||
|
||||
// 2. 注入近轮历史与用户习惯偏好,供对话 LLM(不含本轮刚追加的用户消息,本轮由 RawInput 传入)
|
||||
if s.UserCtx != nil {
|
||||
s.UserCtx.RecentDialogue = s.collectRecentDialogue(8)
|
||||
if s.CtxMgr != nil && s.UserID > 0 {
|
||||
if pref, err := s.CtxMgr.GetUserPreference(ctx, s.UserID); err == nil && pref != nil {
|
||||
s.UserCtx.PersonalizedGuidance = ctxmgr.NewPreferenceReflector().GeneratePromptGuidance(pref)
|
||||
if pref.Name != "" && s.UserCtx.User.Name == "" {
|
||||
s.UserCtx.User.Name = pref.Name
|
||||
}
|
||||
if pref.Role != "" && s.UserCtx.User.Role == "" {
|
||||
s.UserCtx.User.Role = pref.Role
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 协调调度与校验 (L2 协调层)
|
||||
// 2. 协调调度与校验 (L2 协调层)
|
||||
if streamer != nil {
|
||||
streamer.EmitPhase("dialogue_generating", "正在整理回复...", "assistant", 0, 0, 40)
|
||||
}
|
||||
@@ -162,7 +138,7 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
|
||||
return nil, fmt.Errorf("coordination execution failed: %w", err)
|
||||
}
|
||||
|
||||
// 4. 更新上下文与挂起预期生命周期
|
||||
// 3. 更新上下文与挂起预期生命周期
|
||||
if coordRes.UpdatedContext != nil {
|
||||
s.UserCtx = coordRes.UpdatedContext
|
||||
}
|
||||
@@ -189,11 +165,10 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
|
||||
s.Messages = append(s.Messages, asstMsg)
|
||||
|
||||
if s.CtxMgr != nil {
|
||||
asstMeta := map[string]any{"project_id": s.ProjectID}
|
||||
_ = s.CtxMgr.RecordUserMessage(ctx, s.ID, 0, "assistant", coordRes.NaturalReply, string(coordRes.Intent), string(recRes.Branch), asstMeta)
|
||||
_ = s.CtxMgr.RecordUserMessage(ctx, s.ID, 0, "assistant", coordRes.NaturalReply, string(coordRes.Intent), string(recRes.Branch), nil)
|
||||
}
|
||||
|
||||
// 5. 流式推送参谋自然语言回复与决策锦囊
|
||||
// 4. 流式推送参谋自然语言回复与决策锦囊
|
||||
if streamer != nil {
|
||||
if coordRes.NaturalReply != "" {
|
||||
streamer.EmitDelta(coordRes.NaturalReply)
|
||||
@@ -206,7 +181,6 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
|
||||
|
||||
return &ChatResponse{
|
||||
ReplyText: coordRes.NaturalReply,
|
||||
ReplySource: coordRes.ReplySource,
|
||||
Branch: recRes.Branch,
|
||||
IntentResult: recRes,
|
||||
CoordinationResult: coordRes,
|
||||
@@ -215,19 +189,3 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
|
||||
PendingExpectation: s.UserCtx.PendingExpectation,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// collectRecentDialogue 收集本轮之前的近 N 条消息供 LLM 上下文(排除末尾刚写入的本轮用户消息)
|
||||
func (s *Session) collectRecentDialogue(limit int) []coordinator.DialogueTurn {
|
||||
if limit <= 0 || len(s.Messages) <= 1 {
|
||||
return nil
|
||||
}
|
||||
prior := s.Messages[:len(s.Messages)-1]
|
||||
if len(prior) > limit {
|
||||
prior = prior[len(prior)-limit:]
|
||||
}
|
||||
out := make([]coordinator.DialogueTurn, 0, len(prior))
|
||||
for _, m := range prior {
|
||||
out = append(out, coordinator.DialogueTurn{Role: m.Role, Content: m.Content})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
package coordinator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/agent"
|
||||
"chat-agent-system/internal/stream"
|
||||
)
|
||||
|
||||
const (
|
||||
ReplySourcePiAgent = "pi-agent"
|
||||
ReplySourceTemplate = "template"
|
||||
dialogueHistoryMax = 8
|
||||
dialogueTimeout = 3 * time.Minute
|
||||
)
|
||||
|
||||
// DialogueTurn 多轮对话历史片段(不落盘到 JSON 快照)
|
||||
type DialogueTurn struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// generateDialogueReply 通过 pi-agent 生成参谋自然语言答复;失败返回空串由调用方回退模板。
|
||||
func (e *CoordinatorEngine) generateDialogueReply(
|
||||
ctx context.Context,
|
||||
userInput string,
|
||||
userCtx *UserProjectContext,
|
||||
) (reply string, source string) {
|
||||
if e == nil || e.dialogueBackend == nil || strings.TrimSpace(userInput) == "" {
|
||||
return "", ReplySourceTemplate
|
||||
}
|
||||
|
||||
workRoot := e.dataDir
|
||||
if workRoot == "" {
|
||||
workRoot = "./data/dev"
|
||||
}
|
||||
sessionKey := "anon"
|
||||
if userCtx != nil && userCtx.SnapshotID != "" {
|
||||
sessionKey = userCtx.SnapshotID
|
||||
} else if userCtx != nil && userCtx.Project.ID != "" {
|
||||
sessionKey = userCtx.Project.ID
|
||||
}
|
||||
// 每轮独立子目录,避免复用上一轮残留的 reply.md(曾导致「问驳回却答疲劳寿命」)
|
||||
workDir := filepath.Join(
|
||||
workRoot,
|
||||
"workspaces",
|
||||
"dialogue",
|
||||
sanitizePathSegment(sessionKey),
|
||||
fmt.Sprintf("turn_%d", time.Now().UnixNano()),
|
||||
)
|
||||
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
||||
return "", ReplySourceTemplate
|
||||
}
|
||||
|
||||
prompt := buildDialoguePrompt(userInput, userCtx)
|
||||
instructions := `你是「水木东方」医疗器械「项目助手」——温和、靠谱、会替用户着想的项目助理。
|
||||
定位:以助理身份与用户友好协作。能力覆盖商务研判、工程方案与法规合规,但对外只以「项目助手」自称,不要分饰多角色,也不要自称「小队/参谋/总工/法规专家」。
|
||||
|
||||
协作立场(优先遵守):
|
||||
1. 站在用户视角:结合其姓名/角色、当前阶段、待办、近期对话与已知习惯偏好,揣摩本轮提问背后想达成的效果(如准备汇报、消解风险、推进文档、对外沟通、弄清技术点)。
|
||||
2. 先回应核心诉求,再补一两句温和的辅助内容:可落地的下一步、需留意的点,或「如果您接下来是想…,我可以帮您…」。
|
||||
3. 主动但轻量地给建议:通常给 1~2 个最贴合的选项,用商量口吻;不强推、不推销阶段、不堆选项清单。
|
||||
4. 若上下文含「用户个性化工作习惯与偏好」,优先遵循其沟通风格与关注重点来组织答复。
|
||||
|
||||
回复风格:
|
||||
1. 语气温和、友好、自然;可用适度口语与关心式过渡(如「好的」「我帮您看一下」),保持专业分寸。
|
||||
2. 严谨有重点:先结论/直接回答,再补必要依据;篇幅适中——有实质内容,但不冗长堆砌。
|
||||
3. 直接回答本轮问题;闲聊可轻松回应,并可自然关联当前项目。
|
||||
4. 技术机理、法规、工程问题须给出有实质内容的专业解答,不要回避成阶段推销话术。
|
||||
5. 若用户询问受控文档驳回/退回原因或整改要点,必须紧扣本轮问题与已知驳回理由作答,禁止复述无关的上一轮技术长文。
|
||||
6. 不要声称已修改系统配置、用户身份或受控文档状态;不要伪造已执行的工作流结果。
|
||||
7. 只把最终答复写入 reply.md,使用中文,不要输出无关元数据。`
|
||||
|
||||
if st := stream.FromContext(ctx); st != nil {
|
||||
st.EmitPhase("dialogue_generating", "正在生成项目助手回复...", "assistant", 0, 0, 60)
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithTimeout(ctx, dialogueTimeout)
|
||||
defer cancel()
|
||||
|
||||
startedAt := time.Now()
|
||||
res, err := e.dialogueBackend.Execute(runCtx, agent.Request{
|
||||
WorkDir: workDir,
|
||||
Prompt: prompt,
|
||||
Instructions: instructions,
|
||||
Targets: []string{"reply.md"},
|
||||
Timeout: dialogueTimeout,
|
||||
})
|
||||
if err != nil || res == nil || !res.Success {
|
||||
return "", ReplySourceTemplate
|
||||
}
|
||||
|
||||
content := ""
|
||||
if b, ok := res.Patches["reply.md"]; ok {
|
||||
content = string(b)
|
||||
}
|
||||
// 仅当本轮执行后新写入的 reply.md 才可读盘,禁止吃到历史残留
|
||||
if strings.TrimSpace(content) == "" {
|
||||
replyPath := filepath.Join(workDir, "reply.md")
|
||||
if info, statErr := os.Stat(replyPath); statErr == nil && !info.ModTime().Before(startedAt.Add(-2*time.Second)) {
|
||||
if raw, readErr := os.ReadFile(replyPath); readErr == nil {
|
||||
content = string(raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return "", ReplySourceTemplate
|
||||
}
|
||||
return content, ReplySourcePiAgent
|
||||
}
|
||||
|
||||
func buildDialoguePrompt(userInput string, userCtx *UserProjectContext) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("【项目上下文】\n")
|
||||
if userCtx != nil {
|
||||
proj := userCtx.Project
|
||||
name := proj.Name
|
||||
if name == "" {
|
||||
name = "未命名医疗器械项目"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- 项目名称: %s\n", name))
|
||||
if userCtx.User.Name != "" {
|
||||
sb.WriteString(fmt.Sprintf("- 当前用户: %s", userCtx.User.Name))
|
||||
if userCtx.User.Role != "" {
|
||||
sb.WriteString(fmt.Sprintf("(%s)", userCtx.User.Role))
|
||||
}
|
||||
if userCtx.User.ProjectRole != "" {
|
||||
sb.WriteString(fmt.Sprintf(" / 项目角色: %s", userCtx.User.ProjectRole))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if proj.CustomerName != "" {
|
||||
sb.WriteString(fmt.Sprintf("- 客户: %s\n", proj.CustomerName))
|
||||
}
|
||||
if proj.DeviceType != "" {
|
||||
sb.WriteString(fmt.Sprintf("- 器械类型: %s\n", proj.DeviceType))
|
||||
}
|
||||
if proj.CurrentStage != "" {
|
||||
sb.WriteString(fmt.Sprintf("- 当前阶段: %s\n", proj.CurrentStage))
|
||||
}
|
||||
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 userCtx.PendingExpectation != nil && userCtx.PendingExpectation.ContextSnippet != "" {
|
||||
sb.WriteString(fmt.Sprintf("- 上一轮挂起建议摘要: %s\n", userCtx.PendingExpectation.ContextSnippet))
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
if g := strings.TrimSpace(userCtx.PersonalizedGuidance); g != "" {
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(g)
|
||||
if !strings.HasSuffix(g, "\n") {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
if len(userCtx.RecentDialogue) > 0 {
|
||||
sb.WriteString("\n【近期对话】\n")
|
||||
turns := userCtx.RecentDialogue
|
||||
if len(turns) > dialogueHistoryMax {
|
||||
turns = turns[len(turns)-dialogueHistoryMax:]
|
||||
}
|
||||
for _, t := range turns {
|
||||
role := t.Role
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s: %s\n", role, t.Content))
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n【本轮用户】\n")
|
||||
sb.WriteString(userInput)
|
||||
sb.WriteString("\n\n请结合上下文,以温和助理口吻作答,并写入 reply.md。")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func sanitizePathSegment(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "anon"
|
||||
}
|
||||
replacer := strings.NewReplacer("/", "_", "\\", "_", "..", "_", " ", "_")
|
||||
out := replacer.Replace(s)
|
||||
if len(out) > 80 {
|
||||
out = out[:80]
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user