219 lines
8.6 KiB
Go
219 lines
8.6 KiB
Go
package coordinator
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"chat-agent-system/internal/agent"
|
|
"chat-agent-system/internal/dialoguectx"
|
|
"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
|
|
}
|
|
|
|
if userCtx != nil {
|
|
userCtx.RealtimeSnippet = ""
|
|
userCtx.RealtimeUnavailable = false
|
|
if e.realtimeSearcher != nil && e.resolveRealtimeQuery(ctx, userInput) {
|
|
if st := stream.FromContext(ctx); st != nil {
|
|
st.EmitPhase("dialogue_realtime_search", "正在检索实时信息...", "assistant", 0, 0, 40)
|
|
}
|
|
md, searchErr := e.realtimeSearcher.Search(ctx, userInput)
|
|
if searchErr != nil || strings.TrimSpace(md) == "" {
|
|
userCtx.RealtimeUnavailable = true
|
|
} else {
|
|
userCtx.RealtimeSnippet = strings.TrimSpace(md)
|
|
}
|
|
}
|
|
}
|
|
|
|
e.ensureProjectDialogueSnippet(ctx, userCtx)
|
|
prompt := buildDialoguePrompt(userInput, userCtx)
|
|
instructions := `你是「水木东方」医疗器械「项目助手」——温和、靠谱、会替用户着想的项目助理。
|
|
定位:以助理身份与用户友好协作。能力覆盖商务研判、工程方案与法规合规,但对外只以「项目助手」自称,不要分饰多角色,也不要自称「小队/参谋/总工/法规专家」。
|
|
|
|
协作立场(优先遵守):
|
|
1. 站在用户视角:结合其姓名/角色、当前阶段、待办、近期对话与已知习惯偏好,揣摩本轮提问背后想达成的效果(如准备汇报、消解风险、推进文档、对外沟通、弄清技术点)。
|
|
2. 先回应核心诉求,再补一两句温和的辅助内容:可落地的下一步、需留意的点,或「如果您接下来是想…,我可以帮您…」。
|
|
3. 主动但轻量地给建议:通常给 1~2 个最贴合的选项,用商量口吻;不强推、不推销阶段、不堆选项清单。
|
|
4. 若上下文含「用户个性化工作习惯与偏好」,优先遵循其沟通风格与关注重点来组织答复。
|
|
|
|
回复风格:
|
|
1. 语气温和、友好、自然;可用适度口语与关心式过渡(如「好的」「我帮您看一下」),保持专业分寸。
|
|
2. 严谨有重点:先结论/直接回答,再补必要依据;篇幅适中——有实质内容,但不冗长堆砌。
|
|
3. 直接回答本轮问题;闲聊可轻松回应,并可自然关联当前项目。
|
|
4. 技术机理、法规、工程问题须给出有实质内容的专业解答,不要回避成阶段推销话术。
|
|
5. 若用户询问受控文档驳回/退回原因或整改要点,必须紧扣本轮问题与已知驳回理由作答,禁止复述无关的上一轮技术长文。
|
|
6. 不要声称已修改系统配置、用户身份或受控文档状态;不要伪造已执行的工作流结果。
|
|
7. 只把最终答复写入 reply.md,使用中文,不要输出无关元数据。
|
|
8. 事实与引用:涉及法规标准、项目数据、历史结论时,优先依据【参考知识】与【项目基准与协同上下文】作答并沿用〔引用键〕格式;两者未覆盖且无把握时,明确告知"暂无充分依据",严禁编造标准编号、条款内容或项目事实。
|
|
9. 时间锚定:答复中涉及今天/明天/本周等时间表述,一律以提示词【项目基准与协同上下文】中的当前时间为准;天气、新闻等实时信息须依靠工具检索结果,无检索结果时明确说明无法提供实时数据,严禁自行编造日期或实时数值。
|
|
10. 隐私与跨用户边界:【相关历史片段】与【项目基准与协同上下文】中涉及其他成员姓名、个人沟通习惯、私人事实的内容仅用于理解语境,不得在答复中复述、评价或转述给当前用户;引用项目事件仅限客观结论(如谁完成了什么交付物)。
|
|
11. 身份一致性:始终以「项目助手」自称,不得扮演医生/监管审评员等法定角色给出诊疗或注册裁决性结论,法规与型检解读须提示"以官方发文与实际检测为准"。`
|
|
|
|
content, reason := e.runDialogueBackend(ctx, userCtx, prompt, instructions, "dialogue_generating", "正在生成项目助手回复...")
|
|
if reason != "" || strings.TrimSpace(content) == "" {
|
|
return "", ReplySourceTemplate
|
|
}
|
|
return content, ReplySourcePiAgent
|
|
}
|
|
|
|
// runDialogueBackend 在独立工作目录执行一轮 reply.md 生成。失败时 content 为空,reason 供观测。
|
|
func (e *CoordinatorEngine) runDialogueBackend(
|
|
ctx context.Context,
|
|
userCtx *UserProjectContext,
|
|
prompt, instructions, phaseName, phaseText string,
|
|
) (content string, reason string) {
|
|
if e == nil || e.dialogueBackend == nil {
|
|
return "", "backend_nil"
|
|
}
|
|
workRoot := strings.TrimSpace(e.dataDir)
|
|
if workRoot == "" {
|
|
// U5: runtime roots come from config/workspace; never invent a data/dev fallback.
|
|
return "", "no_data_dir"
|
|
}
|
|
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 "", "mkdir_failed"
|
|
}
|
|
if st := stream.FromContext(ctx); st != nil && phaseName != "" {
|
|
st.EmitPhase(phaseName, phaseText, "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 {
|
|
if err != nil {
|
|
return "", err.Error()
|
|
}
|
|
if res != nil && strings.TrimSpace(res.ErrorReason) != "" {
|
|
return "", res.ErrorReason
|
|
}
|
|
return "", "execute_failed"
|
|
}
|
|
|
|
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 "", "empty_reply"
|
|
}
|
|
return content, ""
|
|
}
|
|
|
|
func buildDialoguePrompt(userInput string, userCtx *UserProjectContext) string {
|
|
composer := NewMemoryComposer()
|
|
return composer.ComposeDialoguePrompt(userInput, userCtx)
|
|
}
|
|
|
|
// ensureProjectDialogueSnippet fills UserCtx.ProjectDialogueSnippet from shared project dialogue.
|
|
func (e *CoordinatorEngine) ensureProjectDialogueSnippet(ctx context.Context, userCtx *UserProjectContext) {
|
|
if e == nil || userCtx == nil || e.ctxMgr == nil {
|
|
return
|
|
}
|
|
if !dialoguectx.InjectionEnabled() {
|
|
userCtx.ProjectDialogueSnippet = ""
|
|
return
|
|
}
|
|
if strings.TrimSpace(userCtx.ProjectDialogueSnippet) != "" {
|
|
return
|
|
}
|
|
projectID := parseProjectID(userCtx.Project.ID)
|
|
if projectID <= 0 {
|
|
return
|
|
}
|
|
collector := &dialoguectx.Collector{MemoryFallback: e.ctxMgr}
|
|
pkg, err := collector.CollectAndPackage(ctx, dialoguectx.CollectOptions{
|
|
ProjectID: projectID,
|
|
MaxDays: 3,
|
|
MaxTurns: 40,
|
|
})
|
|
if err != nil || pkg == nil {
|
|
return
|
|
}
|
|
userCtx.ProjectDialogueSnippet = dialoguectx.RenderAssistantSnippet(pkg)
|
|
}
|
|
|
|
func parseProjectID(id string) int64 {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return 0
|
|
}
|
|
n, err := strconv.ParseInt(id, 10, 64)
|
|
if err != nil || n <= 0 {
|
|
return 0
|
|
}
|
|
return n
|
|
}
|
|
|
|
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
|
|
}
|