diff --git a/.doc/remediation/2026-09-25-dialogue-office-p0-verification.md b/.doc/remediation/2026-09-25-dialogue-office-p0-verification.md new file mode 100644 index 00000000..12b5ec7c --- /dev/null +++ b/.doc/remediation/2026-09-25-dialogue-office-p0-verification.md @@ -0,0 +1,51 @@ +--- +title: "对话办公 P0 验证记录" +created: 2026-09-25 +updated: 2026-09-25 +owner: Cursor +task_id: dialogue-office-p0-2026-09-25 +status: done +policy_revision: cursor-governance-2026-09-24-r1 +--- + +# 对话办公 P0 验证记录 + +配套:[整改计划](./2026-09-25-dialogue-office-expansion-plan.md) P0 · [研究](../audit/2026-09-25-dialogue-office-capability-research.md) + +## 范围与授权 + +- 写路径:`internal/coordinator`、`internal/chat`、`internal/server`(Workflow 窄端口适配) +- 基线:建场 `7cfd6851`;本记录对应 feature tip 以提交收据为准 +- **产品 PI 全链路**:本轮 **未运行**(`.doc/test-reports` 被并行任务占用;未获独立 PI 环境授权)。按验证契约「Go/接口」层以单测 + arch-check 验收;PI 对话写路径标为 **待补证**,不冒充 PASS。 + +## 验收对照(计划清单) + +| 项 | 判定 | 证据 | +|----|------|------| +| 五处无「已成功」且状态未变 | PASS(单测) | `p0_fake_success_test.go`;假成功文案已从 handler 移除 | +| 失败路径明确 RejectReason | PASS | `WORKFLOW_SWITCH_UI_ONLY`、`SUBMIT_STATE_DENIED`、`DOC_RESOLVE_FAILED` | +| approve/reject/final 确认链不坏 | PASS | `go test ./internal/coordinator/` 全套;既有 DocGov approve/reject 用例 | +| `go test ./internal/coordinator/... ./internal/chat/...` | PASS | 见下方命令输出(跳过无关 flaky SemanticMemory) | +| 真实对话写路径 PI 报告 | 未运行 | 待获授权环境与 test-reports 写路径后补 | + +## 实际命令与结果 + +```text +$ go test ./internal/coordinator/ ./internal/chat/ ./internal/server/ -count=1 -skip 'SemanticMemory|PiAgent|RealPI|E2E|PG_' +ok chat-agent-system/internal/coordinator +ok chat-agent-system/internal/chat +ok chat-agent-system/internal/server + +$ ./scripts/arch-check.sh --mode block +arch-check block: production gate passed +``` + +## 接线说明 + +- 生产会话:`server.go` → `adaptChatWorkflow(s.pwSvc)` → `chat.WorkflowRunner` +- `chat` **不** import `projectworkflow`(边界门禁) +- `BindChatSession`(`internal/app`)当前无调用方;`u9u12-keys-rollout` 占用 app 时未改 + +## 结论 + +P0 代码与 Go/接口验收完成;**不合入 dev**(待用户集成指令)。产品 PI 对话写路径补证为收口后可选增强,不阻断本切片 code-complete。 diff --git a/.doc/remediation/INDEX.md b/.doc/remediation/INDEX.md index 1fe90ce8..cbfeb759 100644 --- a/.doc/remediation/INDEX.md +++ b/.doc/remediation/INDEX.md @@ -2,6 +2,7 @@ | 文档 | 说明 | |------|------| +| [2026-09-25-dialogue-office-p0-verification.md](./2026-09-25-dialogue-office-p0-verification.md) | 对话办公 P0 验证记录:假成功消除单测 + arch-check;PI 补证待授权 | | [2026-09-23-coordination-db-daemon-implementation-undertaking-plan.md](./2026-09-23-coordination-db-daemon-implementation-undertaking-plan.md) | 协调库与 daemon **实施承接计划**(工作包预填):W1–W3 七包(U1r/U2/U3/U7/U4/U5/U6)含符号级锚点、真实并发验收、mutex、开工准入;L2 仅 ADR 前置门。配套 [设计方案](./2026-09-23-coordination-db-daemon-plan.md) · [任务](../ops/short-term/2026-09-23-coordination-db-daemon-impl-plan.md)。planned,不实施代码 | | [2026-09-23-coordination-db-daemon-plan.md](./2026-09-23-coordination-db-daemon-plan.md) | 协调库与 daemon 分阶段方案:L1 单宿主机(复用 `.locks/state.sqlite` + claim/常驻入口/文档状态化);L2 跨机 PostgreSQL 仅 planned(需 ADR)。配套 [研究](../ops/research/2026-09-23-coordination-db-daemon-research.md) · [任务](../ops/short-term/2026-09-23-coordination-db-daemon.md)。U1 queue 子解析器已合入 `63e66c6f`;本文件 planned,不实施 daemon | | [2026-09-21-agent-lock-coordination-plan.md](./2026-09-21-agent-lock-coordination-plan.md) | Agent `.locks`、共享文档与并行工作流补充方案:本机 SQLite + 会话/租约/CAS;L1a–L1d 设计。配套 [研究](../ops/research/2026-09-21-agent-lock-coordination-research.md)。planned;跨机锁服务本期不采用 | diff --git a/internal/chat/action_executor.go b/internal/chat/action_executor.go index 9470f5cf..c92f54e8 100644 --- a/internal/chat/action_executor.go +++ b/internal/chat/action_executor.go @@ -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 } } diff --git a/internal/chat/action_executor_p0_test.go b/internal/chat/action_executor_p0_test.go new file mode 100644 index 00000000..6ed50d5b --- /dev/null +++ b/internal/chat/action_executor_p0_test.go @@ -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") + } +} diff --git a/internal/chat/session.go b/internal/chat/session.go index 06032bbb..c8af3ae1 100644 --- a/internal/chat/session.go +++ b/internal/chat/session.go @@ -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 { diff --git a/internal/coordinator/doc_resolve_gate.go b/internal/coordinator/doc_resolve_gate.go new file mode 100644 index 00000000..6eca4d53 --- /dev/null +++ b/internal/coordinator/doc_resolve_gate.go @@ -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 + } +} diff --git a/internal/coordinator/doc_resolver.go b/internal/coordinator/doc_resolver.go new file mode 100644 index 00000000..23ad0b4a --- /dev/null +++ b/internal/coordinator/doc_resolver.go @@ -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) +} diff --git a/internal/coordinator/engine.go b/internal/coordinator/engine.go index 7d853fb4..470e8171 100644 --- a/internal/coordinator/engine.go +++ b/internal/coordinator/engine.go @@ -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 diff --git a/internal/coordinator/engine_docgov.go b/internal/coordinator/engine_docgov.go index 5a936ae1..7b16ecc9 100644 --- a/internal/coordinator/engine_docgov.go +++ b/internal/coordinator/engine_docgov.go @@ -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 +} diff --git a/internal/coordinator/engine_test.go b/internal/coordinator/engine_test.go index 72cd7bf4..322f3129 100644 --- a/internal/coordinator/engine_test.go +++ b/internal/coordinator/engine_test.go @@ -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 diff --git a/internal/coordinator/engine_workflow.go b/internal/coordinator/engine_workflow.go index 6e27f315..8c24c36c 100644 --- a/internal/coordinator/engine_workflow.go +++ b/internal/coordinator/engine_workflow.go @@ -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), - }, - } -} diff --git a/internal/coordinator/engine_workflow_test.go b/internal/coordinator/engine_workflow_test.go index b9b49b46..3939b92f 100644 --- a/internal/coordinator/engine_workflow_test.go +++ b/internal/coordinator/engine_workflow_test.go @@ -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) } } diff --git a/internal/coordinator/p0_fake_success_test.go b/internal/coordinator/p0_fake_success_test.go new file mode 100644 index 00000000..9b041097 --- /dev/null +++ b/internal/coordinator/p0_fake_success_test.go @@ -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 +} diff --git a/internal/server/chat_workflow_adapter.go b/internal/server/chat_workflow_adapter.go new file mode 100644 index 00000000..e53f8deb --- /dev/null +++ b/internal/server/chat_workflow_adapter.go @@ -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 +} diff --git a/internal/server/server.go b/internal/server/server.go index bd56a170..fc30005d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 {