merge: candidate dialogue-office-p0-2026-09-25 into dev

Resolve server.go ActionServices wiring on accessor-style Server (Workflow via adaptChatWorkflow).
This commit is contained in:
weichen
2026-09-25 15:42:05 +00:00
15 changed files with 1098 additions and 138 deletions
+126 -4
View File
@@ -13,12 +13,35 @@ import (
"chat-agent-system/internal/taskrun"
)
// WorkflowStartRequest is the narrow DTO for starting a workflow run from dialogue.
type WorkflowStartRequest struct {
RunID string
ProjectID int64
WorkflowKey string
StartedBy int64
Status string
}
// WorkflowStartResult is the narrow receipt after CreateWorkflowRun.
type WorkflowStartResult struct {
RunID string
}
// WorkflowRunner creates workflow run records (composition adapts projectworkflow.Service).
type WorkflowRunner interface {
CreateWorkflowRun(ctx context.Context, req WorkflowStartRequest) (*WorkflowStartResult, error)
}
// ActionServices 对话层执行 PendingAction 所需的后端服务。
// 具体 chatflow / intentflow / taskrun 实现由组合根经窄端口注入。
//
// 生产主路径(server 会话装配)已接线:Workflow = AdaptChatWorkflow(s.pwSvc)。
// BindChatSession(internal/app)若启用亦应适配 rt.PW。
type ActionServices struct {
DocGov *docgov.GovernanceEngine
TaskRuns TaskStarter
IntentPlans IntentPlans
Workflow WorkflowRunner
Environment string
}
@@ -44,6 +67,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 +104,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 +191,30 @@ 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())
created, err := s.Actions.Workflow.CreateWorkflowRun(ctx, WorkflowStartRequest{
RunID: runID,
ProjectID: s.ProjectID,
WorkflowKey: key,
StartedBy: uid,
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 +266,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
}
}
+92
View File
@@ -0,0 +1,92 @@
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"
)
type stubWorkflowRunner struct {
last WorkflowStartRequest
err error
}
func (s *stubWorkflowRunner) CreateWorkflowRun(_ context.Context, req WorkflowStartRequest) (*WorkflowStartResult, error) {
if s.err != nil {
return nil, s.err
}
s.last = req
return &WorkflowStartResult{RunID: req.RunID}, 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(context.Background(), &coordinator.PendingAction{Kind: "workflow.run", WorkflowKey: "main"}, userCtx)
if err != nil {
t.Fatalf("workflow.run: %v", err)
}
if stub.last.ProjectID != 42 || stub.last.WorkflowKey != "main" || stub.last.StartedBy != 7 {
t.Fatalf("unexpected run %+v", stub.last)
}
if res.RunID == "" {
t.Fatal("expected run id")
}
}
+1
View File
@@ -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 {
+176
View File
@@ -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
}
}
+166
View File
@@ -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)
}
+22 -8
View File
@@ -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
+161 -49
View File
@@ -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
}
+19 -3
View File
@@ -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
+40 -72
View File
@@ -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),
},
}
}
+2 -2
View File
@@ -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
}
+36
View File
@@ -0,0 +1,36 @@
package server
import (
"context"
"chat-agent-system/internal/chat"
"chat-agent-system/internal/projectworkflow"
)
// chatWorkflowAdapter adapts projectworkflow.Service to chat.WorkflowRunner
// without letting the chat package import projectworkflow (module boundary).
type chatWorkflowAdapter struct {
svc *projectworkflow.Service
}
func adaptChatWorkflow(svc *projectworkflow.Service) chat.WorkflowRunner {
if svc == nil {
return nil
}
return chatWorkflowAdapter{svc: svc}
}
func (a chatWorkflowAdapter) CreateWorkflowRun(ctx context.Context, req chat.WorkflowStartRequest) (*chat.WorkflowStartResult, error) {
startedBy := req.StartedBy
created, err := a.svc.CreateWorkflowRun(ctx, &projectworkflow.WorkflowRun{
RunID: req.RunID,
ProjectID: req.ProjectID,
WorkflowKey: req.WorkflowKey,
StartedBy: &startedBy,
Status: req.Status,
})
if err != nil {
return nil, err
}
return &chat.WorkflowStartResult{RunID: created.RunID}, nil
}
+1
View File
@@ -614,6 +614,7 @@ func (s *Server) getOrCreateSession(
DocGov: s.gov(),
TaskRuns: app.AdaptChatTaskStarter(s.taskRuns()),
IntentPlans: app.AdaptChatIntentPlans(s.intentPlans()),
Workflow: adaptChatWorkflow(s.pwSvc()),
Environment: env,
}
if s.vaultIngest != nil {