Files

237 lines
9.1 KiB
Go

package coordinator
import (
"context"
"fmt"
"log/slog"
"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"
ReplySourceKB = "kb"
dialogueHistoryMax = 8
dialogueTimeout = 3 * time.Minute
)
// DialogueTurn 多轮对话历史片段(不落盘到 JSON 快照)
type DialogueTurn struct {
Role string `json:"role"`
Content string `json:"content"`
}
// generateDialogueReply 通过 pi-agent 生成参谋自然语言答复。
// 失败时 reply 为空,fallbackReason 说明早退原因,供调用方回退并写入 metadata。
func (e *CoordinatorEngine) generateDialogueReply(
ctx context.Context,
userInput string,
userCtx *UserProjectContext,
) (reply string, source string, fallbackReason string) {
if strings.TrimSpace(userInput) == "" {
return dialogueFallback(ctx, "empty_input", nil)
}
if e == nil || e.dialogueBackend == nil {
return dialogueFallback(ctx, "backend_nil", nil)
}
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) == "" {
if reason == "" {
reason = "empty_reply"
}
return dialogueFallback(ctx, reason, nil)
}
return strings.TrimSpace(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 dialogueFallback(ctx context.Context, reason string, err error) (string, string, string) {
if err != nil {
slog.WarnContext(ctx, "dialogue_reply_fallback", "reason", reason, "err", err.Error())
} else {
slog.WarnContext(ctx, "dialogue_reply_fallback", "reason", reason)
}
return "", ReplySourceTemplate, reason
}
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
}