feat(coordinator): P0 消除对话主链路假成功
claim/submit 经 DocResolver+确认卡后 PendingAction 真写 docgov;proposal 默认分支改澄清;workflow.switch 诚实拒绝(UI专属);workflow.run 确认后走 CreateWorkflowRun(ActionServices.Workflow 由合入时接线)。
This commit is contained in:
@@ -10,15 +10,22 @@ import (
|
||||
"chat-agent-system/internal/contracts/vocabulary"
|
||||
"chat-agent-system/internal/coordinator"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/projectworkflow"
|
||||
"chat-agent-system/internal/taskrun"
|
||||
)
|
||||
|
||||
// WorkflowRunner creates workflow run records (narrow port; composition injects projectworkflow.Service).
|
||||
type WorkflowRunner interface {
|
||||
CreateWorkflowRun(ctx context.Context, run *projectworkflow.WorkflowRun) (*projectworkflow.WorkflowRun, error)
|
||||
}
|
||||
|
||||
// ActionServices 对话层执行 PendingAction 所需的后端服务。
|
||||
// 具体 chatflow / intentflow / taskrun 实现由组合根经窄端口注入。
|
||||
type ActionServices struct {
|
||||
DocGov *docgov.GovernanceEngine
|
||||
TaskRuns TaskStarter
|
||||
IntentPlans IntentPlans
|
||||
Workflow WorkflowRunner
|
||||
Environment string
|
||||
}
|
||||
|
||||
@@ -44,6 +51,32 @@ func actorName(userCtx *coordinator.UserProjectContext) string {
|
||||
return "用户"
|
||||
}
|
||||
|
||||
// EnsureDocResolver wires ListDocResolver from DocGov when the engine has none yet.
|
||||
func (s *Session) EnsureDocResolver() {
|
||||
if s == nil || s.Coordinator == nil || s.Actions == nil || s.Actions.DocGov == nil {
|
||||
return
|
||||
}
|
||||
if s.Coordinator.HasDocResolver() {
|
||||
return
|
||||
}
|
||||
gov := s.Actions.DocGov
|
||||
s.Coordinator.SetDocResolver(&coordinator.ListDocResolver{
|
||||
List: func(ctx context.Context, projectID int64) ([]coordinator.DocListItem, error) {
|
||||
rows, err := gov.ListWorkflowDocumentsByProject(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]coordinator.DocListItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, coordinator.DocListItem{
|
||||
DocID: r.DocID, Title: r.Title, State: r.LifecycleState, TaskKey: r.TaskKey,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Session) executeAction(ctx context.Context, action *coordinator.PendingAction, userCtx *coordinator.UserProjectContext) (*ActionExecResult, error) {
|
||||
if action == nil || s.Actions == nil {
|
||||
return nil, fmt.Errorf("action services not configured")
|
||||
@@ -55,6 +88,52 @@ func (s *Session) executeAction(ctx context.Context, action *coordinator.Pending
|
||||
name := actorName(userCtx)
|
||||
|
||||
switch action.Kind {
|
||||
case "docgov.claim":
|
||||
if s.Actions.DocGov == nil {
|
||||
return nil, fmt.Errorf("docgov engine not available")
|
||||
}
|
||||
doc, err := s.Actions.DocGov.RecordDocumentViewed(ctx, action.DocID, uid, vocabulary.RoleAuthor, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userCtx != nil {
|
||||
label := action.DocID
|
||||
if doc != nil && doc.Title != "" {
|
||||
label = doc.Title
|
||||
}
|
||||
found := false
|
||||
for _, d := range userCtx.UserDocs.Claimed {
|
||||
if d == label || d == action.DocID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
userCtx.UserDocs.Claimed = append(userCtx.UserDocs.Claimed, label)
|
||||
}
|
||||
}
|
||||
return &ActionExecResult{DocState: string(doc.CurrentState), Message: "认领完成"}, nil
|
||||
|
||||
case "docgov.submit_review":
|
||||
if s.Actions.DocGov == nil {
|
||||
return nil, fmt.Errorf("docgov engine not available")
|
||||
}
|
||||
doc, err := s.Actions.DocGov.GetDocument(ctx, action.DocID)
|
||||
if err != nil || doc == nil {
|
||||
return nil, fmt.Errorf("文档不存在或无法读取")
|
||||
}
|
||||
if doc.CurrentState != vocabulary.StateDrafting {
|
||||
return nil, fmt.Errorf("仅编制中(drafting)文档可送审,当前状态:%s", doc.CurrentState)
|
||||
}
|
||||
if doc.AuthorID > 0 && doc.AuthorID != uid {
|
||||
return nil, fmt.Errorf("仅编制责任人可送审")
|
||||
}
|
||||
updated, err := s.Actions.DocGov.SubmitForReview(ctx, action.DocID, uid, vocabulary.RoleAuthor, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ActionExecResult{DocState: string(updated.CurrentState), Message: "送审完成"}, nil
|
||||
|
||||
case "docgov.review_approve":
|
||||
if s.Actions.DocGov == nil {
|
||||
return nil, fmt.Errorf("docgov engine not available")
|
||||
@@ -96,6 +175,31 @@ func (s *Session) executeAction(ctx context.Context, action *coordinator.Pending
|
||||
}
|
||||
return &ActionExecResult{DocState: string(doc.CurrentState), Message: "驳回完成"}, nil
|
||||
|
||||
case "workflow.run":
|
||||
if s.Actions.Workflow == nil {
|
||||
return nil, fmt.Errorf("workflow service not available")
|
||||
}
|
||||
key := strings.TrimSpace(action.WorkflowKey)
|
||||
if key == "" && userCtx != nil {
|
||||
key = userCtx.Project.ActiveWorkflowKey
|
||||
}
|
||||
if key == "" {
|
||||
key = "main"
|
||||
}
|
||||
runID := fmt.Sprintf("wf_run_%d", time.Now().UnixNano())
|
||||
startedBy := uid
|
||||
created, err := s.Actions.Workflow.CreateWorkflowRun(ctx, &projectworkflow.WorkflowRun{
|
||||
RunID: runID,
|
||||
ProjectID: s.ProjectID,
|
||||
WorkflowKey: key,
|
||||
StartedBy: &startedBy,
|
||||
Status: "running",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ActionExecResult{RunID: created.RunID, Message: "工作流已启动"}, nil
|
||||
|
||||
case "taskrun.resume", "taskrun.restart":
|
||||
if s.Actions.TaskRuns == nil {
|
||||
return nil, fmt.Errorf("task run services not available")
|
||||
@@ -147,20 +251,23 @@ func renderActionReply(action *coordinator.PendingAction, result *ActionExecResu
|
||||
return "操作已完成。"
|
||||
}
|
||||
switch action.Kind {
|
||||
case "docgov.claim":
|
||||
return fmt.Sprintf("已确认认领《%s》(当前状态:%s)。您可以开始修订正文或提交审核。", docName, result.DocState)
|
||||
case "docgov.submit_review":
|
||||
return fmt.Sprintf("已确认送审!《%s》已进入审核锁定(当前状态:%s)。", docName, result.DocState)
|
||||
case "docgov.review_approve":
|
||||
return fmt.Sprintf("已确认审核通过!《%s》已流转至批准环节(当前状态:%s)。", docName, result.DocState)
|
||||
case "docgov.approver_approve":
|
||||
return fmt.Sprintf("已确认终审批准!《%s》已被法定受控锁定并正式归档生效(当前状态:%s)。", docName, result.DocState)
|
||||
case "docgov.reject":
|
||||
return fmt.Sprintf("已确认执行驳回操作!《%s》已退回(当前状态:%s),驳回意见已同步下发。", docName, result.DocState)
|
||||
case "workflow.run":
|
||||
return fmt.Sprintf("工作流已启动(run_id=%s)。您可在工作流面板查看进度。", result.RunID)
|
||||
case "taskrun.resume":
|
||||
return fmt.Sprintf("续跑任务已启动(run_id=%s)。您可在任务面板查看进度。", result.RunID)
|
||||
case "taskrun.restart":
|
||||
return fmt.Sprintf("任务已重新启动(run_id=%s)。您可在任务面板查看进度。", result.RunID)
|
||||
default:
|
||||
if result.Message != "" {
|
||||
return result.Message
|
||||
}
|
||||
return "操作已完成。"
|
||||
return result.Message
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/bizdev"
|
||||
"chat-agent-system/internal/config"
|
||||
"chat-agent-system/internal/coordinator"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/projectworkflow"
|
||||
)
|
||||
|
||||
type stubWorkflowRunner struct {
|
||||
last *projectworkflow.WorkflowRun
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubWorkflowRunner) CreateWorkflowRun(_ context.Context, run *projectworkflow.WorkflowRun) (*projectworkflow.WorkflowRun, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
s.last = run
|
||||
return run, nil
|
||||
}
|
||||
|
||||
func TestExecuteAction_DocGovClaimAndSubmit(t *testing.T) {
|
||||
cfg := &config.Config{DataDir: t.TempDir()}
|
||||
svc := bizdev.MustNewService(t, cfg)
|
||||
docGov := bizdev.Snapshot(svc).Gov
|
||||
ctx := context.Background()
|
||||
|
||||
docID := "doc_proj1_intake_p0"
|
||||
_, err := docGov.CreateOrGetDocument(
|
||||
ctx, docID, 1, "intake", "需求表", "V0.5", "",
|
||||
1, "张工", 2, "李主管", 3, "王总监",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateOrGetDocument: %v", err)
|
||||
}
|
||||
_ = docGov.MarkDocumentContentUpdated(ctx, docID, "hash-p0", false)
|
||||
|
||||
sess := &Session{
|
||||
UserID: 1,
|
||||
ProjectID: 1,
|
||||
Actions: &ActionServices{DocGov: docGov},
|
||||
}
|
||||
userCtx := &coordinator.UserProjectContext{
|
||||
User: coordinator.UserInfo{ID: "1", Name: "张工"},
|
||||
}
|
||||
|
||||
claimRes, err := sess.executeAction(ctx, &coordinator.PendingAction{Kind: "docgov.claim", DocID: docID}, userCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
if claimRes.Message != "认领完成" {
|
||||
t.Fatalf("claim message=%q", claimRes.Message)
|
||||
}
|
||||
if len(userCtx.UserDocs.Claimed) == 0 {
|
||||
t.Fatal("expected Claimed list update after successful claim")
|
||||
}
|
||||
|
||||
submitRes, err := sess.executeAction(ctx, &coordinator.PendingAction{Kind: "docgov.submit_review", DocID: docID}, userCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("submit: %v", err)
|
||||
}
|
||||
if submitRes.DocState != string(docgov.StateReviewing) {
|
||||
t.Fatalf("expected reviewing, got %s", submitRes.DocState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteAction_WorkflowRun(t *testing.T) {
|
||||
stub := &stubWorkflowRunner{}
|
||||
sess := &Session{
|
||||
UserID: 7,
|
||||
ProjectID: 42,
|
||||
Actions: &ActionServices{Workflow: stub},
|
||||
}
|
||||
userCtx := &coordinator.UserProjectContext{
|
||||
User: coordinator.UserInfo{ID: "7", Name: "运维"},
|
||||
Project: coordinator.ProjectInfo{ActiveWorkflowKey: "main"},
|
||||
}
|
||||
res, err := sess.executeAction(ctxBackground(), &coordinator.PendingAction{Kind: "workflow.run", WorkflowKey: "main"}, userCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("workflow.run: %v", err)
|
||||
}
|
||||
if stub.last == nil || stub.last.ProjectID != 42 || stub.last.WorkflowKey != "main" {
|
||||
t.Fatalf("unexpected run %+v", stub.last)
|
||||
}
|
||||
if res.RunID == "" {
|
||||
t.Fatal("expected run id")
|
||||
}
|
||||
}
|
||||
|
||||
func ctxBackground() context.Context { return context.Background() }
|
||||
@@ -464,6 +464,7 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
|
||||
if streamer != nil {
|
||||
streamer.EmitPhase("dialogue_generating", "正在整理回复...", "assistant", 0, 0, 40)
|
||||
}
|
||||
s.EnsureDocResolver()
|
||||
coordRes, err := s.Coordinator.ProcessIntent(ctx, recRes, s.UserCtx)
|
||||
if err != nil {
|
||||
if streamer != nil {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package coordinator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/contracts/vocabulary"
|
||||
"chat-agent-system/internal/intent"
|
||||
)
|
||||
|
||||
// resolveTargetDocGate runs the DocResolver mistouch gate.
|
||||
// Returns (doc, nil) on unique hit; otherwise a clarification/reject CoordinationResult.
|
||||
func (e *CoordinatorEngine) resolveTargetDocGate(
|
||||
ctx context.Context,
|
||||
cand *intent.CandidateIntent,
|
||||
userCtx *UserProjectContext,
|
||||
) (*ResolvedDoc, *CoordinationResult) {
|
||||
ref := ""
|
||||
if cand != nil && cand.FilledSlots != nil {
|
||||
ref = strings.TrimSpace(cand.FilledSlots[intent.SlotTargetDoc])
|
||||
}
|
||||
active := ""
|
||||
if userCtx != nil && userCtx.InteractiveCard != nil && userCtx.InteractiveCard.CurrentStatus == "active" {
|
||||
active = userCtx.InteractiveCard.TargetDocID
|
||||
}
|
||||
if active == "" && userCtx != nil {
|
||||
snap := userCtx.ToSnapshot()
|
||||
if snap != nil {
|
||||
active = snap.ActiveDocID
|
||||
}
|
||||
}
|
||||
projectID := int64(0)
|
||||
if userCtx != nil {
|
||||
projectID = parseDecimalID(userCtx.Project.ID)
|
||||
}
|
||||
|
||||
if e == nil || e.docResolver == nil {
|
||||
// Soft fallback for unit tests without resolver: treat ref as DocID.
|
||||
if ref == "" || ref == "当前受控文档" {
|
||||
return nil, &CoordinationResult{
|
||||
Status: StatusClarificationRequired,
|
||||
Intent: cand.Intent,
|
||||
Branch: cand.Branch,
|
||||
ClarificationPrompt: "请明确要操作的受控文档名称或文档 ID(不会默认选择第一份文档)。",
|
||||
NaturalReply: "需要您点名目标文档后才能继续,请回复文档标题或文档 ID。",
|
||||
UpdatedContext: userCtx,
|
||||
}
|
||||
}
|
||||
return &ResolvedDoc{DocID: ref, Title: ref}, nil
|
||||
}
|
||||
|
||||
doc, ok, candidates, err := e.docResolver.ResolveDoc(ctx, projectID, ref, active)
|
||||
if err != nil {
|
||||
return nil, &CoordinationResult{
|
||||
Status: StatusRejected,
|
||||
Intent: cand.Intent,
|
||||
Branch: cand.Branch,
|
||||
RejectReason: "DOC_RESOLVE_FAILED",
|
||||
NaturalReply: fmt.Sprintf("文档解析失败:%v", err),
|
||||
UpdatedContext: userCtx,
|
||||
}
|
||||
}
|
||||
if ok && doc != nil {
|
||||
return doc, nil
|
||||
}
|
||||
prompt := "未唯一匹配到受控文档。请从下列候选中点名一份(回复标题或文档 ID),系统不会默认选择第一份:"
|
||||
if len(candidates) == 0 {
|
||||
prompt = "当前项目没有可匹配的受控文档。请先确认工作流文档已创建,并点名文档标题或 ID。"
|
||||
} else {
|
||||
prompt += "\n- " + strings.Join(candidates, "\n- ")
|
||||
}
|
||||
return nil, &CoordinationResult{
|
||||
Status: StatusClarificationRequired,
|
||||
Intent: cand.Intent,
|
||||
Branch: cand.Branch,
|
||||
ClarificationPrompt: prompt,
|
||||
NaturalReply: prompt,
|
||||
UpdatedContext: userCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func buildConfirmCard(
|
||||
cardTypeTitle, docID, docTitle, section, proposed, impact string,
|
||||
confirmActionID, confirmLabel string,
|
||||
extraPayload map[string]string,
|
||||
) (*intent.InteractiveCardPayload, *intent.PendingExpectation) {
|
||||
payload := map[string]string{
|
||||
"target_doc": docID,
|
||||
"target_action": confirmActionID,
|
||||
}
|
||||
for k, v := range extraPayload {
|
||||
if v != "" {
|
||||
payload[k] = v
|
||||
}
|
||||
}
|
||||
if section != "" {
|
||||
payload["target_section"] = section
|
||||
}
|
||||
cardID := fmt.Sprintf("card_%s_%d", confirmActionID, time.Now().UnixNano())
|
||||
card := &intent.InteractiveCardPayload{
|
||||
CardID: cardID,
|
||||
CardType: "CONFIRMATION",
|
||||
Title: cardTypeTitle,
|
||||
TargetDocID: docID,
|
||||
TargetDocTitle: fmt.Sprintf("《%s》", docTitle),
|
||||
TargetSectionID: section,
|
||||
SectionTitle: section,
|
||||
ProposedContent: proposed,
|
||||
ImpactRisk: impact,
|
||||
CurrentStatus: "active",
|
||||
TTLTurns: 3,
|
||||
CreatedAt: time.Now(),
|
||||
Actions: []intent.CardAction{
|
||||
{
|
||||
ActionID: confirmActionID,
|
||||
Label: confirmLabel,
|
||||
Style: "primary",
|
||||
IsAffirmativeDefault: true,
|
||||
Payload: payload,
|
||||
},
|
||||
{
|
||||
ActionID: "cancel",
|
||||
Label: "取消",
|
||||
Style: "subtle",
|
||||
Payload: map[string]string{"action": "cancel"},
|
||||
},
|
||||
},
|
||||
}
|
||||
exp := &intent.PendingExpectation{
|
||||
ExpectationID: "exp_" + card.CardID,
|
||||
ExpectedIntent: intent.IntentDocGovProposalDecide,
|
||||
TargetAction: confirmActionID,
|
||||
Prompt: "请确认是否执行该操作",
|
||||
AffirmativeWord: "确认",
|
||||
DeclineWord: "取消",
|
||||
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||||
Payload: payload,
|
||||
AllowAffirmative: true,
|
||||
CreatedAt: time.Now(),
|
||||
DefaultOption: &intent.ExpectationOption{
|
||||
OptionID: confirmActionID,
|
||||
Title: confirmLabel,
|
||||
Intent: intent.IntentDocGovProposalDecide,
|
||||
Slots: payload,
|
||||
},
|
||||
}
|
||||
return card, exp
|
||||
}
|
||||
|
||||
func displayDocTitle(doc *ResolvedDoc) string {
|
||||
if doc == nil {
|
||||
return "当前受控文档"
|
||||
}
|
||||
if doc.Title != "" {
|
||||
return doc.Title
|
||||
}
|
||||
return doc.DocID
|
||||
}
|
||||
|
||||
func commentRoleAllowed(role string, state vocabulary.DocumentState) bool {
|
||||
r := strings.ToLower(strings.TrimSpace(role))
|
||||
switch state {
|
||||
case vocabulary.StateDrafting:
|
||||
return r == "author" || r == "reviewer" || r == "system" || r == "composer"
|
||||
case vocabulary.StateReviewing:
|
||||
return r == "reviewer" || r == "author" || r == "system" || r == "composer"
|
||||
case vocabulary.StateApproving:
|
||||
return r == "approver" || r == "reviewer" || r == "system"
|
||||
case vocabulary.StateApproved:
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package coordinator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"chat-agent-system/internal/contracts/vocabulary"
|
||||
)
|
||||
|
||||
// ResolvedDoc is a uniquely resolved controlled document.
|
||||
type ResolvedDoc struct {
|
||||
DocID string
|
||||
Title string
|
||||
State vocabulary.DocumentState
|
||||
TaskKey string
|
||||
}
|
||||
|
||||
// DocListItem is one candidate from the project document inventory.
|
||||
type DocListItem struct {
|
||||
DocID string
|
||||
Title string
|
||||
State vocabulary.DocumentState
|
||||
TaskKey string
|
||||
Aliases []string
|
||||
}
|
||||
|
||||
// DocLister lists workflow documents for a project (typically ListWorkflowDocumentsByProject).
|
||||
type DocLister func(ctx context.Context, projectID int64) ([]DocListItem, error)
|
||||
|
||||
// DocResolver normalizes target_doc references; never silently picks the first match.
|
||||
type DocResolver interface {
|
||||
ResolveDoc(ctx context.Context, projectID int64, ref, activeDocID string) (doc *ResolvedDoc, ok bool, candidates []string, err error)
|
||||
}
|
||||
|
||||
// ListDocResolver resolves against a DocLister inventory.
|
||||
type ListDocResolver struct {
|
||||
List DocLister
|
||||
}
|
||||
|
||||
// ResolveDoc matches by exact doc_id, then unique title/alias; empty/@active uses activeDocID.
|
||||
// 0 or multiple title matches → ok=false with candidate labels (never default to first).
|
||||
func (r *ListDocResolver) ResolveDoc(ctx context.Context, projectID int64, ref, activeDocID string) (*ResolvedDoc, bool, []string, error) {
|
||||
if r == nil || r.List == nil {
|
||||
return nil, false, nil, fmt.Errorf("doc resolver not configured")
|
||||
}
|
||||
ref = strings.TrimSpace(ref)
|
||||
activeDocID = strings.TrimSpace(activeDocID)
|
||||
if ref == "" || ref == "@active" || strings.EqualFold(ref, "当前受控文档") || ref == "当前文档" {
|
||||
if activeDocID == "" {
|
||||
items, err := r.List(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, false, nil, err
|
||||
}
|
||||
return nil, false, candidateLabels(items), nil
|
||||
}
|
||||
ref = activeDocID
|
||||
}
|
||||
|
||||
items, err := r.List(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, false, nil, err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, false, nil, nil
|
||||
}
|
||||
|
||||
for _, it := range items {
|
||||
if strings.EqualFold(it.DocID, ref) {
|
||||
return toResolved(it), true, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
normRef := normalizeDocLabel(ref)
|
||||
var hits []DocListItem
|
||||
for _, it := range items {
|
||||
if normalizeDocLabel(it.Title) == normRef || normalizeDocLabel(it.TaskKey) == normRef {
|
||||
hits = append(hits, it)
|
||||
continue
|
||||
}
|
||||
for _, a := range it.Aliases {
|
||||
if normalizeDocLabel(a) == normRef {
|
||||
hits = append(hits, it)
|
||||
break
|
||||
}
|
||||
}
|
||||
// loose contains only when unique later
|
||||
if strings.Contains(normalizeDocLabel(it.Title), normRef) || strings.Contains(normRef, normalizeDocLabel(it.Title)) {
|
||||
if normalizeDocLabel(it.Title) != "" && normRef != "" {
|
||||
already := false
|
||||
for _, h := range hits {
|
||||
if h.DocID == it.DocID {
|
||||
already = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !already {
|
||||
hits = append(hits, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(hits) == 1 {
|
||||
return toResolved(hits[0]), true, nil, nil
|
||||
}
|
||||
if len(hits) == 0 {
|
||||
return nil, false, candidateLabels(items), nil
|
||||
}
|
||||
return nil, false, candidateLabels(hits), nil
|
||||
}
|
||||
|
||||
func toResolved(it DocListItem) *ResolvedDoc {
|
||||
return &ResolvedDoc{DocID: it.DocID, Title: it.Title, State: it.State, TaskKey: it.TaskKey}
|
||||
}
|
||||
|
||||
func candidateLabels(items []DocListItem) []string {
|
||||
out := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
label := it.Title
|
||||
if label == "" {
|
||||
label = it.DocID
|
||||
}
|
||||
if it.DocID != "" && it.Title != "" {
|
||||
label = fmt.Sprintf("%s(%s)", it.Title, it.DocID)
|
||||
}
|
||||
out = append(out, label)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeDocLabel(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.Trim(s, "《》「」\"' ")
|
||||
s = strings.ToLower(s)
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
return s
|
||||
}
|
||||
|
||||
// PendingSupplementView is a read-only pending ledger row for dialogue answers.
|
||||
type PendingSupplementView struct {
|
||||
DocID string
|
||||
DocTitle string
|
||||
SourceSummary string
|
||||
ImpactReason string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// PendingSupplementReader lists undigested locked-period supplements.
|
||||
type PendingSupplementReader interface {
|
||||
ListPendingSupplements(ctx context.Context, projectID int64, docID string) ([]PendingSupplementView, error)
|
||||
}
|
||||
|
||||
// ReopenListItem is a pending reopen request shown for approve selection.
|
||||
type ReopenListItem struct {
|
||||
RequestID int64
|
||||
DocTasks []string
|
||||
Reason string
|
||||
Status string
|
||||
}
|
||||
|
||||
// ReopenLister lists pending reopen requests for a project (approve path).
|
||||
type ReopenLister interface {
|
||||
ListPendingReopens(ctx context.Context, projectID int64) ([]ReopenListItem, error)
|
||||
}
|
||||
@@ -27,14 +27,15 @@ const (
|
||||
|
||||
// PendingAction 声明对话层需执行的真实动作(由 Session 持有服务后执行)。
|
||||
type PendingAction struct {
|
||||
Kind string // docgov.review_approve | docgov.approver_approve | docgov.reject | taskrun.resume | taskrun.restart
|
||||
DocID string
|
||||
RunID string
|
||||
WorkDir string
|
||||
Reason string
|
||||
Comment string
|
||||
Rollback vocabulary.DocumentState
|
||||
TaskKey string
|
||||
Kind string // docgov.claim | docgov.submit_review | docgov.review_approve | docgov.approver_approve | docgov.reject | workflow.run | taskrun.resume | taskrun.restart
|
||||
DocID string
|
||||
WorkflowKey string
|
||||
RunID string
|
||||
WorkDir string
|
||||
Reason string
|
||||
Comment string
|
||||
Rollback vocabulary.DocumentState
|
||||
TaskKey string
|
||||
}
|
||||
|
||||
// CoordinationResult 协调层给对话层的回执
|
||||
@@ -80,6 +81,7 @@ type CoordinatorEngine struct {
|
||||
// Nil-safe: unset hooks are skipped.
|
||||
OnSupplement func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string)
|
||||
pendingSupplementCard *intent.InteractiveCardPayload
|
||||
docResolver DocResolver
|
||||
}
|
||||
|
||||
// StageTaskRunner is the application owner for ExecuteTask / ExecuteStage.
|
||||
@@ -137,6 +139,18 @@ func (e *CoordinatorEngine) SetOnSupplement(fn func(ctx context.Context, project
|
||||
}
|
||||
}
|
||||
|
||||
// SetDocResolver wires the controlled-document resolution mistouch gate.
|
||||
func (e *CoordinatorEngine) SetDocResolver(r DocResolver) {
|
||||
if e != nil {
|
||||
e.docResolver = r
|
||||
}
|
||||
}
|
||||
|
||||
// HasDocResolver reports whether a document resolution gate is already wired.
|
||||
func (e *CoordinatorEngine) HasDocResolver() bool {
|
||||
return e != nil && e.docResolver != nil
|
||||
}
|
||||
|
||||
func (e *CoordinatorEngine) SetPendingSupplementCard(card *intent.InteractiveCardPayload) {
|
||||
if e == nil {
|
||||
return
|
||||
|
||||
@@ -246,51 +246,75 @@ func (e *CoordinatorEngine) handleDocGovApprove(ctx context.Context, cand *inten
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleDocGovClaim 处理受控文档认领
|
||||
// handleDocGovClaim 解析门 → 确认卡 → 拍板后 PendingAction(docgov.claim)
|
||||
func (e *CoordinatorEngine) handleDocGovClaim(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
|
||||
docName := cand.FilledSlots[intent.SlotTargetDoc]
|
||||
if docName == "" {
|
||||
docName = "当前受控文档"
|
||||
doc, gate := e.resolveTargetDocGate(ctx, cand, userCtx)
|
||||
if gate != nil {
|
||||
return gate, nil
|
||||
}
|
||||
|
||||
// 记录在用户的已认领清单中
|
||||
found := false
|
||||
for _, d := range userCtx.UserDocs.Claimed {
|
||||
if d == docName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
userCtx.UserDocs.Claimed = append(userCtx.UserDocs.Claimed, docName)
|
||||
}
|
||||
|
||||
reply := fmt.Sprintf("您已成功认领《%s》。系统已锁定您作为该文档的法定责任人,您可以开始在线就地修订正文或提交审核。", docName)
|
||||
|
||||
title := displayDocTitle(doc)
|
||||
card, exp := buildConfirmCard(
|
||||
"受控文档认领确认",
|
||||
doc.DocID, title, "",
|
||||
"确认认领该文档并开始编制责任",
|
||||
"认领将记录您已查看并承接该文档的编制责任;不会静默选择其他文档。",
|
||||
"confirm_claim", "确认认领",
|
||||
map[string]string{"task_key": doc.TaskKey},
|
||||
)
|
||||
userCtx.applyCard(card)
|
||||
userCtx.applyPending(exp)
|
||||
reply := fmt.Sprintf("已为《%s》起草认领确认单。请确认文档无误后点击确认或回复「确认」。", title)
|
||||
return &CoordinationResult{
|
||||
Status: StatusAccepted,
|
||||
Intent: cand.Intent,
|
||||
Branch: cand.Branch,
|
||||
NaturalReply: reply,
|
||||
UpdatedContext: userCtx,
|
||||
Status: StatusAccepted,
|
||||
Intent: cand.Intent,
|
||||
Branch: cand.Branch,
|
||||
NaturalReply: reply,
|
||||
InteractiveCard: card,
|
||||
NextExpectation: exp,
|
||||
UpdatedContext: userCtx,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleDocGovSubmitReview 处理受控文档提交审核
|
||||
// handleDocGovSubmitReview 解析门 → drafting 校验 → 确认卡 → 拍板后 PendingAction
|
||||
func (e *CoordinatorEngine) handleDocGovSubmitReview(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
|
||||
docName := cand.FilledSlots[intent.SlotTargetDoc]
|
||||
if docName == "" {
|
||||
docName = "当前受控文档"
|
||||
doc, gate := e.resolveTargetDocGate(ctx, cand, userCtx)
|
||||
if gate != nil {
|
||||
return gate, nil
|
||||
}
|
||||
|
||||
reply := fmt.Sprintf("《%s》已成功提交审核!文档正文已进入受控锁定状态,并已通知审核责任人进行合规审查与批注。", docName)
|
||||
|
||||
state := doc.State
|
||||
// ContextSnapshot hardcodes DocLifecycleState="reviewing" whenever an interactive
|
||||
// card has ActiveDocID — do not treat that as authoritative unless the resolver
|
||||
// already supplied a real lifecycle state.
|
||||
if state != "" && state != vocabulary.StateDrafting {
|
||||
return &CoordinationResult{
|
||||
Status: StatusRejected,
|
||||
Intent: cand.Intent,
|
||||
Branch: cand.Branch,
|
||||
RejectReason: "SUBMIT_STATE_DENIED",
|
||||
NaturalReply: fmt.Sprintf("当前文档处于「%s」阶段,仅编制中(drafting)可送审。", state),
|
||||
UpdatedContext: userCtx,
|
||||
}, nil
|
||||
}
|
||||
title := displayDocTitle(doc)
|
||||
card, exp := buildConfirmCard(
|
||||
"受控文档送审确认",
|
||||
doc.DocID, title, "",
|
||||
"编制完成,提交审核并锁定正文",
|
||||
"送审后文档进入审核锁定:非审核职责人不可改正文。",
|
||||
"confirm_submit_review", "确认送审",
|
||||
map[string]string{"task_key": doc.TaskKey},
|
||||
)
|
||||
userCtx.applyCard(card)
|
||||
userCtx.applyPending(exp)
|
||||
reply := fmt.Sprintf("已为《%s》起草送审确认单。送审将锁定正文并通知审核人,请确认无误后点击确认或回复「确认」。", title)
|
||||
return &CoordinationResult{
|
||||
Status: StatusAccepted,
|
||||
Intent: cand.Intent,
|
||||
Branch: cand.Branch,
|
||||
NaturalReply: reply,
|
||||
UpdatedContext: userCtx,
|
||||
Status: StatusAccepted,
|
||||
Intent: cand.Intent,
|
||||
Branch: cand.Branch,
|
||||
NaturalReply: reply,
|
||||
InteractiveCard: card,
|
||||
NextExpectation: exp,
|
||||
UpdatedContext: userCtx,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -364,6 +388,73 @@ func (e *CoordinatorEngine) handleDocGovProposalDecide(ctx context.Context, cand
|
||||
}, nil
|
||||
}
|
||||
|
||||
if targetAction == "confirm_claim" {
|
||||
payload := mergeDecidePayload(cand, userCtx)
|
||||
if userCtx.InteractiveCard != nil {
|
||||
userCtx.dismissCard("resolved")
|
||||
}
|
||||
userCtx.applyPending(nil)
|
||||
return &CoordinationResult{
|
||||
Status: StatusAccepted,
|
||||
Intent: cand.Intent,
|
||||
Branch: cand.Branch,
|
||||
NaturalReply: fmt.Sprintf("正在处理《%s》的认领…", docName),
|
||||
UpdatedContext: userCtx,
|
||||
ClearExpectation: true,
|
||||
PendingAction: &PendingAction{
|
||||
Kind: "docgov.claim",
|
||||
DocID: docName,
|
||||
TaskKey: payload["task_key"],
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
if targetAction == "confirm_submit_review" {
|
||||
payload := mergeDecidePayload(cand, userCtx)
|
||||
if userCtx.InteractiveCard != nil {
|
||||
userCtx.dismissCard("resolved")
|
||||
}
|
||||
userCtx.applyPending(nil)
|
||||
taskKey := payload["task_key"]
|
||||
return &CoordinationResult{
|
||||
Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch,
|
||||
NaturalReply: fmt.Sprintf("正在处理《%s》的送审…", docName),
|
||||
UpdatedContext: userCtx,
|
||||
ClearExpectation: true,
|
||||
PendingAction: &PendingAction{Kind: "docgov.submit_review", DocID: docName, TaskKey: taskKey},
|
||||
}, nil
|
||||
}
|
||||
|
||||
if targetAction == "confirm_workflow_run" {
|
||||
payload := mergeDecidePayload(cand, userCtx)
|
||||
if userCtx.InteractiveCard != nil {
|
||||
userCtx.dismissCard("resolved")
|
||||
}
|
||||
userCtx.applyPending(nil)
|
||||
wfKey := payload[intent.SlotTargetWorkflow]
|
||||
if wfKey == "" {
|
||||
wfKey = payload["workflow_key"]
|
||||
}
|
||||
if wfKey == "" && userCtx != nil {
|
||||
wfKey = userCtx.Project.ActiveWorkflowKey
|
||||
}
|
||||
if wfKey == "" {
|
||||
wfKey = "main"
|
||||
}
|
||||
return &CoordinationResult{
|
||||
Status: StatusAccepted,
|
||||
Intent: cand.Intent,
|
||||
Branch: cand.Branch,
|
||||
NaturalReply: fmt.Sprintf("正在启动工作流「%s」…", workflowDisplayName(wfKey)),
|
||||
UpdatedContext: userCtx,
|
||||
ClearExpectation: true,
|
||||
PendingAction: &PendingAction{
|
||||
Kind: "workflow.run",
|
||||
WorkflowKey: wfKey,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
if targetAction == "cancel" {
|
||||
if userCtx.InteractiveCard != nil {
|
||||
userCtx.dismissCard("cancelled")
|
||||
@@ -380,19 +471,40 @@ func (e *CoordinatorEngine) handleDocGovProposalDecide(ctx context.Context, cand
|
||||
}, nil
|
||||
}
|
||||
|
||||
action := cand.FilledSlots[intent.SlotProposalAction]
|
||||
var reply string
|
||||
if action == "reject" {
|
||||
reply = "已拒绝该修改变更提案。文档正文保持原样,提案记录已归档。"
|
||||
} else {
|
||||
reply = "已采纳该修改变更提案,补充内容与参数已合并入受控文档。"
|
||||
}
|
||||
|
||||
// 默认分支:禁止假成功——缺确认动作时诚实澄清
|
||||
clarify := "请明确您要执行的文档操作(例如确认驳回、确认终审批准、确认认领或确认送审)。未确认前不会修改文档状态。"
|
||||
return &CoordinationResult{
|
||||
Status: StatusAccepted,
|
||||
Intent: cand.Intent,
|
||||
Branch: cand.Branch,
|
||||
NaturalReply: reply,
|
||||
UpdatedContext: userCtx,
|
||||
Status: StatusClarificationRequired,
|
||||
Intent: cand.Intent,
|
||||
Branch: cand.Branch,
|
||||
ClarificationPrompt: clarify,
|
||||
NaturalReply: clarify,
|
||||
UpdatedContext: userCtx,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mergeDecidePayload(cand *intent.CandidateIntent, userCtx *UserProjectContext) map[string]string {
|
||||
out := map[string]string{}
|
||||
if userCtx != nil && userCtx.InteractiveCard != nil {
|
||||
for _, a := range userCtx.InteractiveCard.Actions {
|
||||
if a.IsAffirmativeDefault && a.Payload != nil {
|
||||
for k, v := range a.Payload {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if userCtx != nil && userCtx.PendingExpectation != nil && userCtx.PendingExpectation.Payload != nil {
|
||||
for k, v := range userCtx.PendingExpectation.Payload {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
if cand != nil && cand.FilledSlots != nil {
|
||||
for k, v := range cand.FilledSlots {
|
||||
if v != "" {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -515,6 +515,16 @@ func TestCoordinatorEngine_DocGovActions(t *testing.T) {
|
||||
if resClaim.Status != coordinator.StatusAccepted {
|
||||
t.Errorf("expected claim accepted, got %s", resClaim.Status)
|
||||
}
|
||||
if resClaim.InteractiveCard == nil && resClaim.Status == coordinator.StatusAccepted {
|
||||
// Without DocResolver, ambiguous/empty ref clarifies; with doc id soft-fallback may still card.
|
||||
}
|
||||
// Soft fallback treats explicit doc id as resolved → confirmation card (no fake success PendingAction).
|
||||
if resClaim.PendingAction != nil {
|
||||
t.Errorf("claim must not emit PendingAction before confirm")
|
||||
}
|
||||
if resClaim.InteractiveCard == nil {
|
||||
t.Errorf("expected claim confirmation card, reply=%q", resClaim.NaturalReply)
|
||||
}
|
||||
|
||||
// 2. DocGovSubmitReview
|
||||
recSubmit := &intent.IntentRecognitionResult{
|
||||
@@ -530,7 +540,13 @@ func TestCoordinatorEngine_DocGovActions(t *testing.T) {
|
||||
t.Fatalf("process submit failed: %v", err)
|
||||
}
|
||||
if resSubmit.Status != coordinator.StatusAccepted {
|
||||
t.Errorf("expected submit accepted, got %s", resSubmit.Status)
|
||||
t.Errorf("expected submit accepted (confirm card), got %s reply=%q", resSubmit.Status, resSubmit.NaturalReply)
|
||||
}
|
||||
if resSubmit.PendingAction != nil {
|
||||
t.Errorf("submit must not emit PendingAction before confirm")
|
||||
}
|
||||
if resSubmit.InteractiveCard == nil {
|
||||
t.Errorf("expected submit confirmation card")
|
||||
}
|
||||
|
||||
// 3. DocGovApprove
|
||||
@@ -574,8 +590,8 @@ func TestCoordinatorEngine_DocGovActions(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("process proposal decide failed: %v", err)
|
||||
}
|
||||
if resProposal.Status != coordinator.StatusAccepted {
|
||||
t.Errorf("expected proposal decide accepted, got %s", resProposal.Status)
|
||||
if resProposal.Status != coordinator.StatusClarificationRequired {
|
||||
t.Errorf("expected proposal default branch to clarify (no fake success), got %s", resProposal.Status)
|
||||
}
|
||||
|
||||
// 5. QueryRegulations
|
||||
|
||||
@@ -3,7 +3,6 @@ package coordinator
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"chat-agent-system/internal/intent"
|
||||
@@ -11,25 +10,17 @@ import (
|
||||
|
||||
func (e *CoordinatorEngine) handleSwitchWorkflow(_ context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
|
||||
targetWF := cand.FilledSlots[intent.SlotTargetWorkflow]
|
||||
active := defaultStr(userCtx.Project.ActiveWorkflowKey, "main")
|
||||
if targetWF == "" {
|
||||
msg := fmt.Sprintf(
|
||||
"切换项目激活工作流属于治理结构操作,仅支持在项目设置界面完成。当前激活工作流为「%s」。如需了解状态,可询问「当前工作流是什么」。",
|
||||
workflowDisplayName(active),
|
||||
)
|
||||
return &CoordinationResult{
|
||||
Status: StatusClarificationRequired,
|
||||
Intent: intent.IntentSwitchWorkflow,
|
||||
ClarificationPrompt: cand.ClarificationQuestion,
|
||||
NaturalReply: cand.ClarificationQuestion,
|
||||
UpdatedContext: userCtx,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if !isProjectCreator(userCtx) {
|
||||
return &CoordinationResult{
|
||||
Status: StatusRejected,
|
||||
Intent: intent.IntentSwitchWorkflow,
|
||||
RejectReason: "WORKFLOW_SWITCH_CREATOR_ONLY",
|
||||
NaturalReply: fmt.Sprintf(
|
||||
"权限不足:切换项目工作流需要项目创建人确认。当前激活工作流为「%s」,您请求切换至「%s」。",
|
||||
userCtx.Project.ActiveWorkflowKey, targetWF,
|
||||
),
|
||||
Status: StatusRejected,
|
||||
Intent: intent.IntentSwitchWorkflow,
|
||||
RejectReason: "WORKFLOW_SWITCH_UI_ONLY",
|
||||
NaturalReply: msg,
|
||||
UpdatedContext: userCtx,
|
||||
}, nil
|
||||
}
|
||||
@@ -38,24 +29,22 @@ func (e *CoordinatorEngine) handleSwitchWorkflow(_ context.Context, cand *intent
|
||||
return &CoordinationResult{
|
||||
Status: StatusAccepted,
|
||||
Intent: intent.IntentSwitchWorkflow,
|
||||
NaturalReply: fmt.Sprintf("当前项目已激活工作流「%s」,无需重复切换。", targetWF),
|
||||
NaturalReply: fmt.Sprintf("当前项目已激活工作流「%s」,无需切换。激活工作流的变更请在项目设置界面操作。", workflowDisplayName(targetWF)),
|
||||
UpdatedContext: userCtx,
|
||||
}, nil
|
||||
}
|
||||
|
||||
confirmMsg := fmt.Sprintf(
|
||||
"您请求将项目工作流从「%s」切换至「%s」(selection_version=%d)。这是高风险写操作,请确认后我再执行切换。",
|
||||
defaultStr(userCtx.Project.ActiveWorkflowKey, "main"),
|
||||
targetWF,
|
||||
userCtx.Project.WorkflowSelectionVersion,
|
||||
msg := fmt.Sprintf(
|
||||
"无法通过对话切换工作流:从「%s」切换至「%s」属于项目治理结构操作,请在项目设置 → 工作流面板完成。对话仅支持查询工作流状态与(在授权下)确认运行已激活的工作流。",
|
||||
workflowDisplayName(active),
|
||||
workflowDisplayName(targetWF),
|
||||
)
|
||||
return &CoordinationResult{
|
||||
Status: StatusClarificationRequired,
|
||||
Intent: intent.IntentSwitchWorkflow,
|
||||
ClarificationPrompt: confirmMsg,
|
||||
NaturalReply: confirmMsg,
|
||||
UpdatedContext: userCtx,
|
||||
NextExpectation: buildWorkflowSwitchExpectation(targetWF, userCtx),
|
||||
Status: StatusRejected,
|
||||
Intent: intent.IntentSwitchWorkflow,
|
||||
RejectReason: "WORKFLOW_SWITCH_UI_ONLY",
|
||||
NaturalReply: msg,
|
||||
UpdatedContext: userCtx,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -71,22 +60,33 @@ func (e *CoordinatorEngine) handleTriggerWorkflow(_ context.Context, cand *inten
|
||||
Intent: intent.IntentTriggerWorkflow,
|
||||
RejectReason: "WORKFLOW_RUN_NOT_AVAILABLE",
|
||||
NaturalReply: fmt.Sprintf(
|
||||
"当前上下文未开放工作流「%s」的运行能力(缺少 %s)。请先切换工作流或确认项目阶段。",
|
||||
"当前上下文未开放工作流「%s」的运行能力(缺少 %s)。请先确认项目阶段或在界面授权后再试。",
|
||||
targetWF, capKey,
|
||||
),
|
||||
UpdatedContext: userCtx,
|
||||
}, nil
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"已理解您要运行工作流「%s」。请在界面工作流面板确认运行,或回复「确认运行 %s 工作流」以继续。",
|
||||
targetWF, workflowDisplayName(targetWF),
|
||||
card, exp := buildConfirmCard(
|
||||
"工作流运行确认",
|
||||
"", workflowDisplayName(targetWF), "",
|
||||
fmt.Sprintf("确认运行工作流「%s」", workflowDisplayName(targetWF)),
|
||||
"确认后将创建一次真实的工作流运行记录;取消则不做任何变更。",
|
||||
"confirm_workflow_run", "确认运行",
|
||||
map[string]string{intent.SlotTargetWorkflow: targetWF, "workflow_key": targetWF},
|
||||
)
|
||||
// TargetDocID unused for workflow; keep card title meaningful
|
||||
card.TargetDocTitle = workflowDisplayName(targetWF)
|
||||
userCtx.applyCard(card)
|
||||
userCtx.applyPending(exp)
|
||||
msg := fmt.Sprintf("已为工作流「%s」起草运行确认单。确认后将创建运行实例;取消则不会启动。", workflowDisplayName(targetWF))
|
||||
return &CoordinationResult{
|
||||
Status: StatusAccepted,
|
||||
Intent: intent.IntentTriggerWorkflow,
|
||||
NaturalReply: msg,
|
||||
UpdatedContext: userCtx,
|
||||
Status: StatusAccepted,
|
||||
Intent: intent.IntentTriggerWorkflow,
|
||||
NaturalReply: msg,
|
||||
InteractiveCard: card,
|
||||
NextExpectation: exp,
|
||||
UpdatedContext: userCtx,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ func (e *CoordinatorEngine) handleHelpCapabilities(_ context.Context, _ *intent.
|
||||
sb.WriteString(fmt.Sprintf("• %s\n", formatCapabilityLabel(a)))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n高风险操作(工作流切换、文档审批、阶段推进)需要明确确认后才会执行。")
|
||||
sb.WriteString("\n高风险操作(文档审批、阶段推进、运行工作流)需要明确确认后才会执行;切换激活工作流请使用项目设置界面。")
|
||||
return &CoordinationResult{
|
||||
Status: StatusAccepted,
|
||||
Intent: intent.IntentHelpCapabilities,
|
||||
@@ -165,17 +165,6 @@ func (e *CoordinatorEngine) handleUnsupportedAction(_ context.Context, _ *intent
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isProjectCreator(userCtx *UserProjectContext) bool {
|
||||
if userCtx == nil || userCtx.Project.CreatorUserID == 0 {
|
||||
return userCtx != nil && userCtx.User.ID == "1"
|
||||
}
|
||||
uid, err := strconv.ParseInt(userCtx.User.ID, 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return uid == userCtx.Project.CreatorUserID || uid == 1
|
||||
}
|
||||
|
||||
func hasCapability(userCtx *UserProjectContext, key string) bool {
|
||||
if userCtx == nil {
|
||||
return false
|
||||
@@ -217,7 +206,7 @@ func formatCapabilityLabel(cap string) string {
|
||||
case cap == "VIEW_DOC":
|
||||
return "查看受控文档"
|
||||
case cap == "SWITCH_WORKFLOW":
|
||||
return "切换项目工作流(需创建人确认)"
|
||||
return "切换项目工作流(仅界面;对话不代执行)"
|
||||
case strings.HasPrefix(cap, "TRIGGER_WORKFLOW:"):
|
||||
return "运行工作流:" + workflowDisplayName(strings.TrimPrefix(cap, "TRIGGER_WORKFLOW:"))
|
||||
case strings.HasPrefix(cap, "TRIGGER_TASK:"):
|
||||
@@ -228,24 +217,3 @@ func formatCapabilityLabel(cap string) string {
|
||||
return cap
|
||||
}
|
||||
}
|
||||
|
||||
func buildWorkflowSwitchExpectation(targetWF string, userCtx *UserProjectContext) *intent.PendingExpectation {
|
||||
return &intent.PendingExpectation{
|
||||
ExpectedIntent: intent.IntentSwitchWorkflow,
|
||||
TargetAction: "workflow.switch",
|
||||
Prompt: fmt.Sprintf("确认切换工作流至 %s", workflowDisplayName(targetWF)),
|
||||
AllowAffirmative: true,
|
||||
DefaultOption: &intent.ExpectationOption{
|
||||
OptionID: "confirm_switch",
|
||||
Title: fmt.Sprintf("确认切换至 %s", workflowDisplayName(targetWF)),
|
||||
Intent: intent.IntentSwitchWorkflow,
|
||||
Slots: map[string]string{
|
||||
intent.SlotTargetWorkflow: targetWF,
|
||||
},
|
||||
},
|
||||
Payload: map[string]string{
|
||||
intent.SlotTargetWorkflow: targetWF,
|
||||
"selection_version": fmt.Sprintf("%d", userCtx.Project.WorkflowSelectionVersion),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@ func TestEngine_SwitchWorkflow_NonCreatorRejected(t *testing.T) {
|
||||
if res.Status != coordinator.StatusRejected {
|
||||
t.Fatalf("expected REJECTED for non-creator, got %s", res.Status)
|
||||
}
|
||||
if res.RejectReason != "WORKFLOW_SWITCH_CREATOR_ONLY" {
|
||||
t.Errorf("expected WORKFLOW_SWITCH_CREATOR_ONLY, got %s", res.RejectReason)
|
||||
if res.RejectReason != "WORKFLOW_SWITCH_UI_ONLY" {
|
||||
t.Errorf("expected WORKFLOW_SWITCH_UI_ONLY, got %s", res.RejectReason)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package coordinator_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/contracts/vocabulary"
|
||||
"chat-agent-system/internal/coordinator"
|
||||
"chat-agent-system/internal/intent"
|
||||
)
|
||||
|
||||
type stubDocResolver struct {
|
||||
doc *coordinator.ResolvedDoc
|
||||
ok bool
|
||||
candidates []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubDocResolver) ResolveDoc(context.Context, int64, string, string) (*coordinator.ResolvedDoc, bool, []string, error) {
|
||||
return s.doc, s.ok, s.candidates, s.err
|
||||
}
|
||||
|
||||
func baseUserCtx() *coordinator.UserProjectContext {
|
||||
return &coordinator.UserProjectContext{
|
||||
User: coordinator.UserInfo{ID: "1", Name: "作者", Role: "engineer", ProjectRole: "composer"},
|
||||
Project: coordinator.ProjectInfo{
|
||||
ID: "9",
|
||||
Name: "demo",
|
||||
ActiveWorkflowKey: "main",
|
||||
CreatorUserID: 1,
|
||||
},
|
||||
AvailableActions: []string{"TRIGGER_WORKFLOW", "TRIGGER_WORKFLOW:main"},
|
||||
BlackboardFacts: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDocGovClaim_ConfirmCardNotFakeSuccess(t *testing.T) {
|
||||
engine := coordinator.NewCoordinatorEngine(nil)
|
||||
engine.SetDocResolver(&stubDocResolver{
|
||||
doc: &coordinator.ResolvedDoc{DocID: "doc_proj9_intake", Title: "需求表", State: vocabulary.StateDrafting, TaskKey: "intake"},
|
||||
ok: true,
|
||||
})
|
||||
userCtx := baseUserCtx()
|
||||
cand := &intent.CandidateIntent{
|
||||
Intent: intent.IntentDocGovClaim,
|
||||
FilledSlots: map[string]string{intent.SlotTargetDoc: "需求表"},
|
||||
}
|
||||
res, err := engine.ProcessIntent(context.Background(), &intent.IntentRecognitionResult{TopIntent: cand}, userCtx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.PendingAction != nil {
|
||||
t.Fatalf("claim must not emit PendingAction before confirm, got %#v", res.PendingAction)
|
||||
}
|
||||
if res.InteractiveCard == nil {
|
||||
t.Fatal("expected confirmation card")
|
||||
}
|
||||
if len(userCtx.UserDocs.Claimed) != 0 {
|
||||
t.Fatalf("must not mutate Claimed before confirm, got %v", userCtx.UserDocs.Claimed)
|
||||
}
|
||||
if res.NaturalReply == "" || containsFakeSuccess(res.NaturalReply) {
|
||||
t.Fatalf("unexpected reply: %q", res.NaturalReply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDocGovSubmitReview_StateDenied(t *testing.T) {
|
||||
engine := coordinator.NewCoordinatorEngine(nil)
|
||||
engine.SetDocResolver(&stubDocResolver{
|
||||
doc: &coordinator.ResolvedDoc{DocID: "doc_x", Title: "X", State: vocabulary.StateReviewing, TaskKey: "intake"},
|
||||
ok: true,
|
||||
})
|
||||
userCtx := baseUserCtx()
|
||||
cand := &intent.CandidateIntent{
|
||||
Intent: intent.IntentDocGovSubmitReview,
|
||||
FilledSlots: map[string]string{intent.SlotTargetDoc: "X"},
|
||||
}
|
||||
res, err := engine.ProcessIntent(context.Background(), &intent.IntentRecognitionResult{TopIntent: cand}, userCtx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != coordinator.StatusRejected || res.RejectReason != "SUBMIT_STATE_DENIED" {
|
||||
t.Fatalf("want SUBMIT_STATE_DENIED, got status=%s reason=%s reply=%q", res.Status, res.RejectReason, res.NaturalReply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDocGovSubmitReview_ConfirmThenPending(t *testing.T) {
|
||||
engine := coordinator.NewCoordinatorEngine(nil)
|
||||
engine.SetDocResolver(&stubDocResolver{
|
||||
doc: &coordinator.ResolvedDoc{DocID: "doc_proj9_intake", Title: "需求表", State: vocabulary.StateDrafting, TaskKey: "intake"},
|
||||
ok: true,
|
||||
})
|
||||
userCtx := baseUserCtx()
|
||||
cand := &intent.CandidateIntent{
|
||||
Intent: intent.IntentDocGovSubmitReview,
|
||||
FilledSlots: map[string]string{intent.SlotTargetDoc: "需求表"},
|
||||
}
|
||||
res, err := engine.ProcessIntent(context.Background(), &intent.IntentRecognitionResult{TopIntent: cand}, userCtx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.InteractiveCard == nil || res.PendingAction != nil {
|
||||
t.Fatalf("want confirm card only, card=%v pending=%v", res.InteractiveCard != nil, res.PendingAction)
|
||||
}
|
||||
|
||||
decide := &intent.CandidateIntent{
|
||||
Intent: intent.IntentDocGovProposalDecide,
|
||||
FilledSlots: map[string]string{
|
||||
"target_action": "confirm_submit_review",
|
||||
intent.SlotTargetDoc: "doc_proj9_intake",
|
||||
"task_key": "intake",
|
||||
},
|
||||
}
|
||||
res2, err := engine.ProcessIntent(context.Background(), &intent.IntentRecognitionResult{TopIntent: decide}, userCtx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res2.PendingAction == nil || res2.PendingAction.Kind != "docgov.submit_review" {
|
||||
t.Fatalf("want docgov.submit_review pending, got %#v", res2.PendingAction)
|
||||
}
|
||||
if res2.PendingAction.DocID != "doc_proj9_intake" {
|
||||
t.Fatalf("doc id = %s", res2.PendingAction.DocID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposalDecide_DefaultBranchClarifies(t *testing.T) {
|
||||
engine := coordinator.NewCoordinatorEngine(nil)
|
||||
userCtx := baseUserCtx()
|
||||
cand := &intent.CandidateIntent{
|
||||
Intent: intent.IntentDocGovProposalDecide,
|
||||
FilledSlots: map[string]string{intent.SlotProposalAction: "accept"},
|
||||
}
|
||||
res, err := engine.ProcessIntent(context.Background(), &intent.IntentRecognitionResult{TopIntent: cand}, userCtx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != coordinator.StatusClarificationRequired {
|
||||
t.Fatalf("want clarification, got %s reply=%q", res.Status, res.NaturalReply)
|
||||
}
|
||||
if containsFakeSuccess(res.NaturalReply) {
|
||||
t.Fatalf("fake success reply: %q", res.NaturalReply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchWorkflow_UIOnlyReject(t *testing.T) {
|
||||
engine := coordinator.NewCoordinatorEngine(nil)
|
||||
userCtx := baseUserCtx()
|
||||
cand := &intent.CandidateIntent{
|
||||
Intent: intent.IntentSwitchWorkflow,
|
||||
FilledSlots: map[string]string{intent.SlotTargetWorkflow: "test_manager"},
|
||||
}
|
||||
res, err := engine.ProcessIntent(context.Background(), &intent.IntentRecognitionResult{TopIntent: cand}, userCtx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != coordinator.StatusRejected || res.RejectReason != "WORKFLOW_SWITCH_UI_ONLY" {
|
||||
t.Fatalf("want UI_ONLY reject, got status=%s reason=%s", res.Status, res.RejectReason)
|
||||
}
|
||||
if res.NextExpectation != nil || res.PendingAction != nil {
|
||||
t.Fatal("must not hang switch confirmation expectation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerWorkflow_ConfirmCard(t *testing.T) {
|
||||
engine := coordinator.NewCoordinatorEngine(nil)
|
||||
userCtx := baseUserCtx()
|
||||
cand := &intent.CandidateIntent{
|
||||
Intent: intent.IntentTriggerWorkflow,
|
||||
FilledSlots: map[string]string{intent.SlotTargetWorkflow: "main"},
|
||||
}
|
||||
res, err := engine.ProcessIntent(context.Background(), &intent.IntentRecognitionResult{TopIntent: cand}, userCtx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.InteractiveCard == nil {
|
||||
t.Fatal("expected run confirmation card")
|
||||
}
|
||||
if res.PendingAction != nil {
|
||||
t.Fatal("must not run before confirm")
|
||||
}
|
||||
decide := &intent.CandidateIntent{
|
||||
Intent: intent.IntentDocGovProposalDecide,
|
||||
FilledSlots: map[string]string{
|
||||
"target_action": "confirm_workflow_run",
|
||||
intent.SlotTargetWorkflow: "main",
|
||||
},
|
||||
}
|
||||
res2, err := engine.ProcessIntent(context.Background(), &intent.IntentRecognitionResult{TopIntent: decide}, userCtx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res2.PendingAction == nil || res2.PendingAction.Kind != "workflow.run" || res2.PendingAction.WorkflowKey != "main" {
|
||||
t.Fatalf("want workflow.run pending, got %#v", res2.PendingAction)
|
||||
}
|
||||
}
|
||||
|
||||
func containsFakeSuccess(s string) bool {
|
||||
for _, p := range []string{"已成功认领", "已成功提交审核", "已采纳该修改变更提案", "已拒绝该修改变更提案", "请在界面工作流面板确认运行"} {
|
||||
if strings.Contains(s, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user