Exact out-of-workflow documents can be summarized when WORKFLOW_SCOPE_MODE=weighted and the caller can still view the active instance; writes stay blocked. Co-authored-by: Cursor <cursoragent@cursor.com>
467 lines
17 KiB
Go
467 lines
17 KiB
Go
package coordinator
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"chat-agent-system/internal/ctxmgr"
|
||
"chat-agent-system/internal/intent"
|
||
"chat-agent-system/internal/kb"
|
||
"chat-agent-system/internal/maturity"
|
||
"chat-agent-system/internal/squad"
|
||
)
|
||
|
||
// UserInfo 用户在系统与项目内的身份
|
||
type UserInfo struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Role string `json:"role"` // 系统角色 (如 engineer, pm, director)
|
||
ProjectRole string `json:"project_role"` // 项目内角色 (如 编制人 composer, 审核人 reviewer, 批准人 approver)
|
||
}
|
||
|
||
// ProjectInfo 项目与工作流当前基准
|
||
type ProjectInfo struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
CustomerName string `json:"customer_name"`
|
||
DeviceType string `json:"device_type"`
|
||
IntendedUse string `json:"intended_use"`
|
||
TargetMarket string `json:"target_market"`
|
||
CreatorUserID int64 `json:"creator_user_id,omitempty"`
|
||
ActiveWorkflowKey string `json:"active_workflow_key,omitempty"`
|
||
WorkflowSelectionVersion int `json:"workflow_selection_version,omitempty"`
|
||
CurrentWorkflow string `json:"current_workflow"`
|
||
CurrentStage maturity.MaturityStage `json:"current_stage"`
|
||
StageRules []string `json:"stage_rules,omitempty"`
|
||
}
|
||
|
||
// UserDocsInfo 用户相关文档清单与认领状态
|
||
type UserDocsInfo struct {
|
||
Composed []string `json:"composed"` // 负责编制的文档
|
||
Reviewed []string `json:"reviewed"` // 负责审核的文档
|
||
Approved []string `json:"approved"` // 负责批准的文档
|
||
Claimed []string `json:"claimed"` // 已认领/已打开查阅的文档
|
||
}
|
||
|
||
// PendingItems 待用户处理或决策的待办项
|
||
type PendingItems struct {
|
||
ConfirmModifications []string `json:"confirm_modifications,omitempty"`
|
||
PendingReviews []string `json:"pending_reviews,omitempty"`
|
||
PendingApprovals []string `json:"pending_approvals,omitempty"`
|
||
MyDraftsInProgress []string `json:"my_drafts_in_progress,omitempty"`
|
||
UnreadSupplements []string `json:"unread_supplements,omitempty"`
|
||
}
|
||
|
||
// ReadableDocument 是已通过项目与阶段权限过滤后的只读文档快照。
|
||
// Markdown 只用于当轮总结,不写入上下文快照或对话元数据。
|
||
type ReadableDocument struct {
|
||
DocID string
|
||
Title string
|
||
TaskKey string
|
||
LifecycleState string
|
||
Version int
|
||
Markdown string
|
||
IsTemplateOnly bool
|
||
// WorkflowKey 是文档归属工作流。CrossWorkflow 为真时正文来自非激活流,仅只读。
|
||
WorkflowKey string
|
||
ActiveWorkflow string
|
||
CrossWorkflow bool
|
||
}
|
||
|
||
// DocumentSummaryReader 由 HTTP 服务端按当前登录用户和项目绑定。
|
||
// 协调层不得直接枚举文档,以免绕过阶段可见性规则。
|
||
type DocumentSummaryReader func(ctx context.Context, target string) (*ReadableDocument, error)
|
||
|
||
// ErrWrongWorkflow 表示目标文档存在但不属于当前激活工作流。
|
||
// 服务端文档读取器按激活流收窄匹配池后,用该错误携带「请先切换工作流」引导话术,
|
||
// 协调层识别后原样回复,不执行任何读/写动作。
|
||
type ErrWrongWorkflow struct {
|
||
Message string
|
||
}
|
||
|
||
func (e *ErrWrongWorkflow) Error() string { return e.Message }
|
||
|
||
// WrongWorkflowGuidance 若错误链上是跨工作流拦截,返回引导话术;否则空串。
|
||
func WrongWorkflowGuidance(err error) string {
|
||
var target *ErrWrongWorkflow
|
||
if errors.As(err, &target) && target != nil && strings.TrimSpace(target.Message) != "" {
|
||
return target.Message
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// ErrDocViewDenied 表示跨工作流文档存在,但当前用户无权查看。
|
||
type ErrDocViewDenied struct {
|
||
Message string
|
||
}
|
||
|
||
func (e *ErrDocViewDenied) Error() string {
|
||
if e == nil {
|
||
return "document view denied"
|
||
}
|
||
return e.Message
|
||
}
|
||
|
||
// DocViewDeniedGuidance 若错误链是跨流查看拒绝,返回话术。
|
||
func DocViewDeniedGuidance(err error) string {
|
||
var target *ErrDocViewDenied
|
||
if errors.As(err, &target) && target != nil && strings.TrimSpace(target.Message) != "" {
|
||
return target.Message
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// UserProjectContext 用户项目级全量统一上下文 (对话系统与执行系统共同消费)
|
||
type UserProjectContext struct {
|
||
mu sync.Mutex
|
||
Version int64 `json:"version"`
|
||
SnapshotID string `json:"snapshot_id"`
|
||
AssembledAt time.Time `json:"assembled_at"`
|
||
User UserInfo `json:"user"`
|
||
Project ProjectInfo `json:"project"`
|
||
UserDocs UserDocsInfo `json:"user_docs"`
|
||
Pending PendingItems `json:"pending"`
|
||
BlackboardFacts map[string]string `json:"blackboard_facts"`
|
||
LatestAdvisorBrief *squad.AdvisorBriefing `json:"latest_advisor_brief,omitempty"`
|
||
PendingExpectation *intent.PendingExpectation `json:"pending_expectation,omitempty"`
|
||
InteractiveCard *intent.InteractiveCardPayload `json:"interactive_card,omitempty"`
|
||
AvailableActions []string `json:"available_actions"`
|
||
RecentEvents []string `json:"recent_events,omitempty"`
|
||
WorkDir string `json:"work_dir,omitempty"`
|
||
// RecentDialogue 近轮对话(供 Phase A 对话 LLM 使用,不序列化到快照)
|
||
RecentDialogue []DialogueTurn `json:"-"`
|
||
// DialogueSummary 早期对话滚动压缩摘要(由 ctxmgr 维护生成,不序列化到快照)
|
||
DialogueSummary string `json:"-"`
|
||
// TaskState 会话级任务状态(DST,记录 active_task, topic_anchor, collected_slots 等)
|
||
TaskState *ctxmgr.ConversationTaskState `json:"task_state,omitempty"`
|
||
// PersonalizedGuidance 用户工作习惯/偏好注入语(由 ctxmgr 编译,供对话 LLM 使用)
|
||
PersonalizedGuidance string `json:"-"`
|
||
// RecalledMemories 按需语义召回的相关历史片段(TopK ≤ 3)
|
||
RecalledMemories []ctxmgr.RecalledMemoryItem `json:"-"`
|
||
// RecalledMemorySnippet 格式化后的相关历史片段 Prompt 文本
|
||
RecalledMemorySnippet string `json:"-"`
|
||
// RewrittenQuery 规则改写补全后的完整独立问句(用于检索与 Prompt 双呈现,不进意图识别)
|
||
RewrittenQuery string `json:"-"`
|
||
// RewriteRules 本轮命中的改写规则列表(如 ellipsis_completion, pronoun_resolution)
|
||
RewriteRules []string `json:"-"`
|
||
// RegulationHits 知识库法规与强标检索命中项(带 CiteKey 引用键,供对话层精准引用)
|
||
RegulationHits []kb.HitItem `json:"-"`
|
||
// UpgradeOffered 路由升级建议冷却标记(每会话至多主动建议一次,防打扰——对齐"参谋不强推"人设)
|
||
UpgradeOffered bool `json:"-"`
|
||
// DocumentSummaryReader 仅用于服务端鉴权后的只读正文访问,不序列化。
|
||
DocumentSummaryReader DocumentSummaryReader `json:"-"`
|
||
}
|
||
|
||
// ContextAssembler 上下文组装服务
|
||
type ContextAssembler struct{}
|
||
|
||
func NewContextAssembler() *ContextAssembler {
|
||
return &ContextAssembler{}
|
||
}
|
||
|
||
// ComputeAvailableActions 根据当前成熟度阶段与角色计算允许执行的操作集合
|
||
func ComputeAvailableActions(stage maturity.MaturityStage, pRole string) []string {
|
||
actions := []string{"QUERY_PROGRESS", "SUPPLEMENT_INFO", "VIEW_DOC"}
|
||
|
||
switch stage {
|
||
case maturity.StageV01_Triage:
|
||
actions = append(actions, "TRIGGER_TASK:intake", "ADVANCE_STAGE:V0.5_feasibility")
|
||
case maturity.StageV05_Feasibility:
|
||
actions = append(actions,
|
||
"TRIGGER_TASK:research",
|
||
"TRIGGER_TASK:feasibility_reg",
|
||
"TRIGGER_TASK:prelim_design",
|
||
"ADVANCE_STAGE:V1.0_quoting",
|
||
)
|
||
case maturity.StageV10_Quoting:
|
||
actions = append(actions, "TRIGGER_TASK:cdo_quote", "LOCK_COMMERCIAL")
|
||
default:
|
||
actions = append(actions, "ADVANCE_STAGE:V0.1_triage")
|
||
}
|
||
|
||
return actions
|
||
}
|
||
|
||
// ComputeAvailableActionsWithWorkflow 结合激活工作流、成熟度阶段与角色权限计算可用操作集合
|
||
func ComputeAvailableActionsWithWorkflow(stage maturity.MaturityStage, pRole string, activeWorkflow string, isCreator bool) []string {
|
||
actions := ComputeAvailableActions(stage, pRole)
|
||
actions = appendUniqueCapability(actions, "QUERY_WORKFLOW")
|
||
if isCreator {
|
||
actions = appendUniqueCapability(actions, "SWITCH_WORKFLOW")
|
||
}
|
||
switch activeWorkflow {
|
||
case "product_manager":
|
||
actions = appendUniqueCapability(actions, "TRIGGER_TASK:system_reqs", "TRIGGER_WORKFLOW:product_manager")
|
||
case "test_manager":
|
||
actions = appendUniqueCapability(actions, "TRIGGER_TASK:test_manager", "TRIGGER_WORKFLOW:test_manager")
|
||
default:
|
||
if activeWorkflow != "" && activeWorkflow != "main" {
|
||
actions = appendUniqueCapability(actions, "TRIGGER_WORKFLOW:"+activeWorkflow)
|
||
}
|
||
}
|
||
return actions
|
||
}
|
||
|
||
func appendUniqueCapability(actions []string, keys ...string) []string {
|
||
seen := make(map[string]struct{}, len(actions))
|
||
for _, a := range actions {
|
||
seen[a] = struct{}{}
|
||
}
|
||
for _, k := range keys {
|
||
if _, ok := seen[k]; ok {
|
||
continue
|
||
}
|
||
seen[k] = struct{}{}
|
||
actions = append(actions, k)
|
||
}
|
||
return actions
|
||
}
|
||
|
||
// ToSnapshot 转换为 L1 意图识别器所需的轻量上下文快照
|
||
func (ctx *UserProjectContext) ToSnapshot() *intent.ContextSnapshot {
|
||
if ctx == nil {
|
||
return nil
|
||
}
|
||
snap := &intent.ContextSnapshot{
|
||
SnapshotID: ctx.SnapshotID,
|
||
ProjectName: ctx.Project.Name,
|
||
CurrentStage: string(ctx.Project.CurrentStage),
|
||
ActiveWorkflowKey: ctx.Project.ActiveWorkflowKey,
|
||
AvailableActions: ctx.AvailableActions,
|
||
PendingExpectation: ctx.PendingExpectation,
|
||
}
|
||
if ctx.User.ProjectRole != "" {
|
||
snap.UserDocRole = ctx.User.ProjectRole
|
||
} else if ctx.User.Role != "" {
|
||
snap.UserDocRole = ctx.User.Role
|
||
}
|
||
if ctx.InteractiveCard != nil && ctx.InteractiveCard.CurrentStatus == "active" {
|
||
snap.ActiveDocID = ctx.InteractiveCard.TargetDocID
|
||
if ctx.InteractiveCard.TargetDocID != "" {
|
||
snap.DocLifecycleState = "reviewing"
|
||
}
|
||
}
|
||
return snap
|
||
}
|
||
|
||
// BuildNextExpectation 根据当前成熟度阶段与项目名构造参谋下一步建议选项草稿
|
||
func BuildNextExpectation(stage maturity.MaturityStage, projectName string) *intent.PendingExpectation {
|
||
if projectName == "" {
|
||
projectName = "本项目"
|
||
}
|
||
|
||
var options []intent.ExpectationOption
|
||
var snippet string
|
||
|
||
switch stage {
|
||
case maturity.StageV01_Triage:
|
||
snippet = fmt.Sprintf("项目「%s」当前处于初始摸底期,基准产品画像已梳理完成。", projectName)
|
||
options = []intent.ExpectationOption{
|
||
{
|
||
OptionID: "1",
|
||
Title: "进入 V0.5 方案成型与法规对齐",
|
||
Description: "自动比选技术路线,并梳理分类路径与型检标准",
|
||
Intent: intent.IntentAdvanceStage,
|
||
Slots: map[string]string{
|
||
intent.SlotTargetStage: string(maturity.StageV05_Feasibility),
|
||
},
|
||
},
|
||
{
|
||
OptionID: "2",
|
||
Title: "补充或微调事实信息",
|
||
Description: "随时告知我客户的最新的预算、目标市场或使用场景",
|
||
Intent: intent.IntentSupplementInfo,
|
||
},
|
||
{
|
||
OptionID: "3",
|
||
Title: "查阅当前立项成熟度与对外沟通建议",
|
||
Description: "获取进展研判与对外商务沟通话术",
|
||
Intent: intent.IntentQueryProgress,
|
||
Slots: map[string]string{
|
||
intent.SlotQueryTopic: "all",
|
||
},
|
||
},
|
||
}
|
||
|
||
case maturity.StageV05_Feasibility:
|
||
snippet = fmt.Sprintf("项目「%s」已完成方案成型与法规对齐,具备进入商务核算的条件。", projectName)
|
||
options = []intent.ExpectationOption{
|
||
{
|
||
OptionID: "1",
|
||
Title: "推进到 V1.0 商业锁定阶段",
|
||
Description: "生成精细化 CDO 工时与成本核算表,锁定立项商务方案",
|
||
Intent: intent.IntentAdvanceStage,
|
||
Slots: map[string]string{
|
||
intent.SlotTargetStage: string(maturity.StageV10_Quoting),
|
||
},
|
||
},
|
||
{
|
||
OptionID: "2",
|
||
Title: "微调设计方案或技术参数",
|
||
Description: "进一步指定元器件品牌、特殊工艺或交付节点",
|
||
Intent: intent.IntentSupplementInfo,
|
||
},
|
||
{
|
||
OptionID: "3",
|
||
Title: "审阅初步系统架构图与法规分类论证说明",
|
||
Description: "查看技术路线比选报告与检验标准分析",
|
||
Intent: intent.IntentQueryProgress,
|
||
Slots: map[string]string{
|
||
intent.SlotQueryTopic: "progress",
|
||
},
|
||
},
|
||
}
|
||
|
||
case maturity.StageV10_Quoting:
|
||
snippet = fmt.Sprintf("项目「%s」已完成商务锁定,报价核算表已就绪。", projectName)
|
||
options = []intent.ExpectationOption{
|
||
{
|
||
OptionID: "1",
|
||
Title: "重新核算或调整报价单参数",
|
||
Description: "调整费率、工时模型或元器件采购预算",
|
||
Intent: intent.IntentTriggerTask,
|
||
Slots: map[string]string{
|
||
intent.SlotTargetTask: "cdo_quote",
|
||
},
|
||
},
|
||
{
|
||
OptionID: "2",
|
||
Title: "生成面向客户负责人的正式沟通函件",
|
||
Description: "草拟发给客户的商务方案总揽与下一步推进排期",
|
||
Intent: intent.IntentQueryProgress,
|
||
Slots: map[string]string{
|
||
intent.SlotQueryTopic: "talk_script",
|
||
},
|
||
},
|
||
}
|
||
|
||
default:
|
||
snippet = fmt.Sprintf("项目「%s」当前处于初始准备阶段。", projectName)
|
||
options = []intent.ExpectationOption{
|
||
{
|
||
OptionID: "1",
|
||
Title: "启动 V0.1 闪电摸底",
|
||
Description: "快速沉淀第一版立项画像并为您准备沟通话术",
|
||
Intent: intent.IntentAdvanceStage,
|
||
Slots: map[string]string{
|
||
intent.SlotTargetStage: string(maturity.StageV01_Triage),
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
var defaultOpt *intent.ExpectationOption
|
||
if len(options) > 0 {
|
||
defaultOpt = &options[0]
|
||
}
|
||
|
||
return &intent.PendingExpectation{
|
||
ContextSnippet: snippet,
|
||
DefaultOption: defaultOpt,
|
||
Options: options,
|
||
AllowAffirmative: true,
|
||
CreatedAt: time.Now(),
|
||
}
|
||
}
|
||
|
||
// AssembleFromWorkspace 基于现有工作区快速组装 UserProjectContext
|
||
func (a *ContextAssembler) AssembleFromWorkspace(workDir string, user UserInfo) (*UserProjectContext, error) {
|
||
snapID := fmt.Sprintf("ctx_%d", time.Now().UnixNano())
|
||
|
||
ctx := &UserProjectContext{
|
||
SnapshotID: snapID,
|
||
AssembledAt: time.Now(),
|
||
User: user,
|
||
WorkDir: workDir,
|
||
BlackboardFacts: make(map[string]string),
|
||
AvailableActions: []string{"QUERY_PROGRESS", "SUPPLEMENT_INFO", "VIEW_DOC"},
|
||
}
|
||
|
||
// 1. 尝试读取 workspace.json
|
||
wsMetaPath := filepath.Join(workDir, "workspace.json")
|
||
if data, err := os.ReadFile(wsMetaPath); err == nil {
|
||
var wsMeta squad.WorkspaceMetadata
|
||
if json.Unmarshal(data, &wsMeta) == nil {
|
||
ctx.Project.Name = wsMeta.ProjectName
|
||
ctx.Project.CustomerName = wsMeta.CustomerName
|
||
ctx.Project.DeviceType = wsMeta.DeviceType
|
||
ctx.Project.IntendedUse = wsMeta.IntendedUse
|
||
ctx.Project.TargetMarket = wsMeta.TargetMarket
|
||
ctx.Project.CurrentWorkflow = wsMeta.TaskKey
|
||
}
|
||
} else {
|
||
// 备选:从目录名推断
|
||
baseName := filepath.Base(workDir)
|
||
if strings.HasPrefix(baseName, "intake_") {
|
||
ctx.Project.CurrentWorkflow = "intake"
|
||
}
|
||
}
|
||
|
||
// 2. 尝试读取 blackboard.json
|
||
bbPath := filepath.Join(workDir, "blackboard.json")
|
||
if data, err := os.ReadFile(bbPath); err == nil {
|
||
var bb squad.BlackboardState
|
||
if json.Unmarshal(data, &bb) == nil {
|
||
for k, v := range bb.Fields {
|
||
ctx.BlackboardFacts[k] = v
|
||
}
|
||
for _, g := range bb.Gaps {
|
||
if g.Blocking && !g.Resolved {
|
||
ctx.Pending.ConfirmModifications = append(ctx.Pending.ConfirmModifications, g.Item)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. 尝试提取阶段与文档
|
||
if ctx.Project.ActiveWorkflowKey != "" {
|
||
ctx.Project.CurrentWorkflow = ctx.Project.ActiveWorkflowKey
|
||
}
|
||
if strings.HasPrefix(ctx.Project.CurrentWorkflow, "V0.1") || ctx.Project.CurrentWorkflow == "intake" {
|
||
ctx.Project.CurrentStage = maturity.StageV01_Triage
|
||
} else if strings.HasPrefix(ctx.Project.CurrentWorkflow, "V0.5") {
|
||
ctx.Project.CurrentStage = maturity.StageV05_Feasibility
|
||
} else if strings.HasPrefix(ctx.Project.CurrentWorkflow, "V1.0") {
|
||
ctx.Project.CurrentStage = maturity.StageV10_Quoting
|
||
}
|
||
|
||
if ctx.Project.CurrentStage == "" {
|
||
ctx.Project.CurrentStage = maturity.StageV01_Triage
|
||
}
|
||
|
||
isCreator := false
|
||
if user.ID != "" && ctx.Project.CreatorUserID > 0 {
|
||
uid, _ := strconv.ParseInt(user.ID, 10, 64)
|
||
isCreator = (uid == ctx.Project.CreatorUserID)
|
||
}
|
||
ctx.AvailableActions = ComputeAvailableActionsWithWorkflow(ctx.Project.CurrentStage, user.ProjectRole, ctx.Project.ActiveWorkflowKey, isCreator)
|
||
|
||
// 4. 优先尝试直接读取持久化的 advisor_brief.json
|
||
briefPath := filepath.Join(workDir, "advisor_brief.json")
|
||
if data, err := os.ReadFile(briefPath); err == nil {
|
||
var brief squad.AdvisorBriefing
|
||
if json.Unmarshal(data, &brief) == nil {
|
||
ctx.LatestAdvisorBrief = &brief
|
||
}
|
||
}
|
||
|
||
// 5. 若无 advisor_brief.json 但有 output.md,尝试从文本抽取
|
||
if ctx.LatestAdvisorBrief == nil {
|
||
outPath := filepath.Join(workDir, "output.md")
|
||
if outBytes, err := os.ReadFile(outPath); err == nil && len(outBytes) > 0 {
|
||
ctx.LatestAdvisorBrief = squad.ExtractAdvisorBriefing(ctx.Project.CurrentWorkflow, string(outBytes), ctx.BlackboardFacts)
|
||
ctx.UserDocs.Composed = append(ctx.UserDocs.Composed, ctx.Project.CurrentWorkflow)
|
||
}
|
||
}
|
||
|
||
return ctx, nil
|
||
}
|