diff --git a/.doc/system-design/2026-09-25-doc-review-lifecycle-design.md b/.doc/system-design/2026-09-25-doc-review-lifecycle-design.md index f29ad3b4..85016dbe 100644 --- a/.doc/system-design/2026-09-25-doc-review-lifecycle-design.md +++ b/.doc/system-design/2026-09-25-doc-review-lifecycle-design.md @@ -26,7 +26,7 @@ stateDiagram-v2 approved --> drafting : reopen_approve ``` -积压表 `document_pending_supplements`(goose `00072`)独立于 `document_records` 状态列。回 drafting 时 `DigestPendingOnReturnToDrafting` 标记 digested 并通知 author。 +积压表 `document_pending_supplements`(goose `00073`)独立于 `document_records` 状态列。回 drafting 时 `DigestPendingOnReturnToDrafting` 标记 digested 并通知 author。 ## 3. 锁定期补充闭环 diff --git a/.doc/test-reports/2026-09-25-doc-review-lifecycle-test-report.md b/.doc/test-reports/2026-09-25-doc-review-lifecycle-test-report.md index 4440891d..d9608260 100644 --- a/.doc/test-reports/2026-09-25-doc-review-lifecycle-test-report.md +++ b/.doc/test-reports/2026-09-25-doc-review-lifecycle-test-report.md @@ -50,9 +50,31 @@ ok chat-agent-system/internal/app/docnotify 0.002s - **实际输出**:4 tests pass(stage matrix / viewer / resolve gates) - **评价判定**:PASS -### 5.4 真实 PI E2E 全链 +### 5.4 对话触发链路(批注 / 重开 / 积压 / 送审真执行) -- **评价判定**:未运行(待授权环境:编制→送审→锁定批注→驳回→积压消化→再送审→批准→归档→重开) +- **测试输入**: + +```bash +go test ./internal/intent/... ./internal/coordinator/... ./internal/chat/ \ + -run 'TestIntentRecognition_DocGov_CommentReopenPending|TestListDocResolver|TestHandleDocGov|TestExecuteAction_' -count=1 +``` + +- **实际输出**(摘录): + +```text +ok chat-agent-system/internal/intent 0.008s +ok chat-agent-system/internal/coordinator 0.005s +ok chat-agent-system/internal/chat 0.005s +``` + +覆盖:查询语气/裸词「重开」负例、批注矩阵剪枝、DocResolver 0/多匹配澄清、送审确认卡→拍板 `docgov.submit_review`、comment/reopen action kinds 成功与权限失败路径。 + +- **评价判定**:PASS(对话触发与误触防护单测) +- **说明**:`TestChatSession_SemanticMemoryRecallAndAsyncTurnRecord` 仍偶发超时,与本变更无关,未纳入本节判定。 + +### 5.5 真实 PI E2E 全链 + +- **评价判定**:未运行(待授权环境:编制→送审→锁定批注→驳回→积压消化→再送审→批准→归档→重开;以及对话通道两步确认) ## 六、自动化测试套件汇总 (Test Suite Execution) @@ -61,9 +83,10 @@ ok chat-agent-system/internal/app/docnotify 0.002s | `go test` docgov / supplementimpact / docnotify | PASS | | `go test ./internal/server -short` | PASS | | `commentPolicy.test.ts` | PASS | +| `go test` intent / coordinator / chat(对话触发子集) | PASS | | 产品 PI E2E | 未运行 | ## 七、测试结论与后续优化建议 (Conclusion & Next Steps) -- 单元与短测覆盖 G1–G3 主路径与负例;G4 审批人逻辑已落代码,需在获授权环境用真实成员/退出成员补 HTTP 验收。 -- 合入后建议:跑 goose `00072`、再跑一次真实 PI 全链并回填本节 5.4。 +- 单元与短测覆盖 G1–G3 主路径与负例;G4 审批人逻辑与对话触发四道防线(语法护栏、DocResolver、角色×阶段、确认卡)已落代码。 +- 合入后建议:跑 goose `00073`、再跑一次真实 PI 全链(含对话送审/批注/重开)并回填本节 5.5。 diff --git a/deploy/postgres/goose/00072_2026-09-25-document-pending-supplements.sql b/deploy/postgres/goose/00073_2026-09-25-document-pending-supplements.sql similarity index 100% rename from deploy/postgres/goose/00072_2026-09-25-document-pending-supplements.sql rename to deploy/postgres/goose/00073_2026-09-25-document-pending-supplements.sql diff --git a/internal/app/app.go b/internal/app/app.go index 55d0939b..6a0e0021 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -20,6 +20,7 @@ import ( "chat-agent-system/internal/auth" "chat-agent-system/internal/authz" "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/chat" "chat-agent-system/internal/config" "chat-agent-system/internal/coordinator" "chat-agent-system/internal/ctxmgr" @@ -32,11 +33,11 @@ import ( "chat-agent-system/internal/kb" "chat-agent-system/internal/maintenance" "chat-agent-system/internal/notification" - "chat-agent-system/internal/supplementimpact" "chat-agent-system/internal/observability" "chat-agent-system/internal/projectinstance" "chat-agent-system/internal/projectworkflow" "chat-agent-system/internal/storage" + "chat-agent-system/internal/supplementimpact" "chat-agent-system/internal/sysadmin" "chat-agent-system/internal/taskrun" "chat-agent-system/internal/traceimpact" @@ -52,39 +53,40 @@ const defaultReminderPollInterval = 15 * time.Minute // Runtime is the unique composition root. cmd/server, tests and workers // construct it once; HTTP adapters must not re-wire domain services. type Runtime struct { - Cfg *config.Config - Biz *bizdev.Service - Engine *coordinator.CoordinatorEngine - Auth *auth.Service - PW *projectworkflow.Service - TaskRuns *taskrun.Manager - Authorizer *authz.Authorizer - Authz *authz.Service - WFDef *workflowdef.Service - Inst *projectinstance.Service - Notify *notification.Service - Resolver *workspace.Resolver - WSCap *workspace.Capability - Commands *taskrun.Commands - Rec intent.Recognizer - Stageflow *stageflow.UseCase - DocNotify *docnotify.Subscriber - ChatLog chatflow.ChatLog - Assembler chatflow.ContextAssembler - Docflow *docflow.Commands - IntentPlans *intentflow.Plans - Pool *pgxpool.Pool - Gov *docgov.GovernanceEngine - Rev *docrevision.Service - Trace *traceimpact.Service + Cfg *config.Config + Biz *bizdev.Service + Engine *coordinator.CoordinatorEngine + Auth *auth.Service + PW *projectworkflow.Service + TaskRuns *taskrun.Manager + Authorizer *authz.Authorizer + Authz *authz.Service + WFDef *workflowdef.Service + Inst *projectinstance.Service + Notify *notification.Service + Resolver *workspace.Resolver + WSCap *workspace.Capability + Commands *taskrun.Commands + Rec intent.Recognizer + Stageflow *stageflow.UseCase + DocNotify *docnotify.Subscriber + ChatLog chatflow.ChatLog + Assembler chatflow.ContextAssembler + Docflow *docflow.Commands + IntentPlans *intentflow.Plans + Pool *pgxpool.Pool + Gov *docgov.GovernanceEngine + Rev *docrevision.Service + Trace *traceimpact.Service SupplementImpact *supplementimpact.Service mutateOwner *MutateOwner - Ctx *ctxmgr.ContextManager - CAS *storage.CAS - FreeDoc *freedoc.Service - KB *kb.Client - SysAdmin *sysadmin.Service - Maintenance *maintenance.Service + ChatReopens chat.ReopenService + Ctx *ctxmgr.ContextManager + CAS *storage.CAS + FreeDoc *freedoc.Service + KB *kb.Client + SysAdmin *sysadmin.Service + Maintenance *maintenance.Service mu sync.Mutex running bool @@ -330,6 +332,7 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) { rt.DocNotify.SkipWrite = func(ctx context.Context) bool { return rt.WriterLive(ctx, events.ConsumerNotify) } + rt.ChatReopens = rt.WireDocDialogueTriggers() envName := "" if cfg != nil { envName = cfg.Env @@ -400,21 +403,21 @@ func sysadminFeatureValues(cfg *config.Config) func() map[string]string { pflags := intentflow.CurrentFeatureFlags() sflags := ctxmgr.CurrentSummaryFlags() values := map[string]string{ - "INTENT_V2_SHADOW": strconv.FormatBool(iflags.IntentV2Shadow), - "INTENT_V2_DECISION": strconv.FormatBool(iflags.IntentV2Decision), - "INTENT_V2_DECISION_TIER": string(iflags.V2DecisionTier), - "WORKFLOW_INTENTS_ENABLED": strconv.FormatBool(iflags.WorkflowIntentsEnabled), - "MULTI_INTENT_ENABLED": strconv.FormatBool(iflags.MultiIntentEnabled), - "MULTI_INTENT_PLAN_TIER": string(pflags.PlanTier), + "INTENT_V2_SHADOW": strconv.FormatBool(iflags.IntentV2Shadow), + "INTENT_V2_DECISION": strconv.FormatBool(iflags.IntentV2Decision), + "INTENT_V2_DECISION_TIER": string(iflags.V2DecisionTier), + "WORKFLOW_INTENTS_ENABLED": strconv.FormatBool(iflags.WorkflowIntentsEnabled), + "MULTI_INTENT_ENABLED": strconv.FormatBool(iflags.MultiIntentEnabled), + "MULTI_INTENT_PLAN_TIER": string(pflags.PlanTier), "INTENT_PLAN_PROJECT_ALLOWLIST": formatProjectAllowlist(pflags.ProjectAllowlist), - "MULTI_INTENT_U9_SEGMENT": strconv.FormatBool(pflags.U9Segment), - "MULTI_INTENT_U10_MUTATE": strconv.FormatBool(pflags.U10Mutate), - "MULTI_INTENT_U11_DOCGOV": strconv.FormatBool(pflags.U11DocGov), - "MULTI_INTENT_U12_RUN_CONTROL": strconv.FormatBool(pflags.U12RunControl), - "WORKFLOW_SCOPE_MODE": string(workflowscope.CurrentMode()), - "SEMANTIC_SUMMARY_SHADOW": strconv.FormatBool(sflags.Shadow), - "SEMANTIC_SUMMARY_REPLACE": strconv.FormatBool(sflags.Replace), - "PI_CHANGE_REPORT_NARRATIVE": strconv.FormatBool(docrevision.PIChangeReportNarrativeEnabled()), + "MULTI_INTENT_U9_SEGMENT": strconv.FormatBool(pflags.U9Segment), + "MULTI_INTENT_U10_MUTATE": strconv.FormatBool(pflags.U10Mutate), + "MULTI_INTENT_U11_DOCGOV": strconv.FormatBool(pflags.U11DocGov), + "MULTI_INTENT_U12_RUN_CONTROL": strconv.FormatBool(pflags.U12RunControl), + "WORKFLOW_SCOPE_MODE": string(workflowscope.CurrentMode()), + "SEMANTIC_SUMMARY_SHADOW": strconv.FormatBool(sflags.Shadow), + "SEMANTIC_SUMMARY_REPLACE": strconv.FormatBool(sflags.Replace), + "PI_CHANGE_REPORT_NARRATIVE": strconv.FormatBool(docrevision.PIChangeReportNarrativeEnabled()), } if cfg != nil { values["AGENT_BACKEND"] = cfg.AgentBackend diff --git a/internal/app/chat_doc_dialogue.go b/internal/app/chat_doc_dialogue.go new file mode 100644 index 00000000..aab6e051 --- /dev/null +++ b/internal/app/chat_doc_dialogue.go @@ -0,0 +1,246 @@ +package app + +import ( + "context" + "fmt" + "strings" + "time" + + "chat-agent-system/internal/chat" + "chat-agent-system/internal/coordinator" + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/projectworkflow" + "chat-agent-system/internal/traceimpact" +) + +// chatReopenAdapter implements chat.ReopenService and coordinator.ReopenLister. +type chatReopenAdapter struct { + trace *traceimpact.Service + gov *docgov.GovernanceEngine + inst *projectinstance.Service + pw *projectworkflow.Service +} + +func (a *chatReopenAdapter) ListPendingReopens(ctx context.Context, projectID int64) ([]chat.ReopenRequestView, error) { + if a == nil || a.trace == nil { + return nil, nil + } + rows, err := a.trace.ListPendingReopens(ctx, projectID) + if err != nil { + return nil, err + } + out := make([]chat.ReopenRequestView, 0, len(rows)) + for _, r := range rows { + out = append(out, chat.ReopenRequestView{ + ID: r.ID, DocTasks: r.DocTasks, Reason: r.Reason, Status: r.Status, + }) + } + return out, nil +} + +func (a *chatReopenAdapter) ListPendingReopensForCoord(ctx context.Context, projectID int64) ([]coordinator.ReopenListItem, error) { + views, err := a.ListPendingReopens(ctx, projectID) + if err != nil { + return nil, err + } + out := make([]coordinator.ReopenListItem, 0, len(views)) + for _, v := range views { + out = append(out, coordinator.ReopenListItem{ + RequestID: v.ID, DocTasks: v.DocTasks, Reason: v.Reason, Status: v.Status, + }) + } + return out, nil +} + +// Ensure interface satisfaction for coordinator.ReopenLister via thin wrapper. +type coordReopenLister struct{ inner *chatReopenAdapter } + +func (c coordReopenLister) ListPendingReopens(ctx context.Context, projectID int64) ([]coordinator.ReopenListItem, error) { + if c.inner == nil { + return nil, nil + } + return c.inner.ListPendingReopensForCoord(ctx, projectID) +} + +func (a *chatReopenAdapter) RequestReopenByDoc(ctx context.Context, projectID int64, taskKey, reason string, userID int64) (chat.ReopenRequestView, error) { + if a == nil || a.trace == nil { + return chat.ReopenRequestView{}, fmt.Errorf("reopen service unavailable") + } + taskKey = normalizeTaskKey(projectID, taskKey) + if taskKey == "" || strings.TrimSpace(reason) == "" { + return chat.ReopenRequestView{}, fmt.Errorf("task_key and reason required") + } + creator := false + if a.pw != nil { + creator, _ = a.pw.IsProjectCreator(ctx, projectID, userID) + } + stageKey, instanceID, seats, err := a.resolveStageForTask(ctx, projectID, userID, taskKey) + if err != nil { + return chat.ReopenRequestView{}, err + } + if !traceimpact.MayRequestReopen(userID, creator, []string{taskKey}, seats) { + return chat.ReopenRequestView{}, fmt.Errorf("无权申请重开:须为该文档职责人或项目创建人") + } + stored, err := a.trace.RequestReopen(ctx, traceimpact.ReopenRequest{ + ProjectID: projectID, InstanceID: instanceID, StageKey: stageKey, + DocTasks: []string{taskKey}, Reason: reason, RequestedBy: userID, + }, creator) + if err != nil { + return chat.ReopenRequestView{}, err + } + return chat.ReopenRequestView{ + ID: stored.ID, DocTasks: stored.DocTasks, Reason: stored.Reason, Status: stored.Status, + }, nil +} + +func (a *chatReopenAdapter) ApproveReopenByID(ctx context.Context, projectID, requestID, userID int64) (chat.ReopenRequestView, error) { + if a == nil || a.trace == nil { + return chat.ReopenRequestView{}, fmt.Errorf("reopen service unavailable") + } + existing, err := a.trace.GetReopen(ctx, requestID) + if err != nil { + return chat.ReopenRequestView{}, err + } + if existing.ProjectID != projectID { + return chat.ReopenRequestView{}, fmt.Errorf("申请不属于该项目") + } + creator := false + if a.pw != nil { + creator, _ = a.pw.IsProjectCreator(ctx, projectID, userID) + } + if !a.mayApprove(ctx, projectID, userID, creator, existing) { + return chat.ReopenRequestView{}, fmt.Errorf("无权批准重开:须为文档批准人或(批准人缺位时)项目创建人") + } + stored, err := a.trace.ApproveReopen(ctx, requestID, userID) + if err != nil { + return chat.ReopenRequestView{}, err + } + return chat.ReopenRequestView{ + ID: stored.ID, DocTasks: stored.DocTasks, Reason: stored.Reason, Status: stored.Status, + }, nil +} + +func (a *chatReopenAdapter) mayApprove(ctx context.Context, projectID, userID int64, creator bool, req traceimpact.ReopenRequest) bool { + hasActiveApprover := false + userIsApprover := false + if a.gov != nil { + for _, taskKey := range req.DocTasks { + doc, err := a.gov.FindByProjectTaskKey(ctx, projectID, taskKey) + if err != nil || doc == nil || doc.ApproverID <= 0 { + continue + } + hasActiveApprover = true + if doc.ApproverID == userID { + userIsApprover = true + } + } + } + if userIsApprover { + return true + } + return !hasActiveApprover && creator +} + +func (a *chatReopenAdapter) resolveStageForTask(ctx context.Context, projectID, userID int64, taskKey string) (stageKey string, instanceID int64, seats []traceimpact.DutySeat, err error) { + if a.inst == nil { + return "", 0, nil, fmt.Errorf("instance service unavailable") + } + settings, err := a.inst.GetSettings(ctx, projectID, userID, true) + if err != nil || settings == nil || settings.Plan == nil { + return "", 0, nil, fmt.Errorf("无法解析工作流阶段") + } + if settings.LiveInstance != nil { + instanceID = settings.LiveInstance.ID + } + for _, stage := range settings.Plan.Stages { + for _, task := range stage.DocTasks { + seats = append(seats, traceimpact.DutySeat{ + TaskKey: task.TaskKey, AuthorID: task.Duties.AuthorUserID, + ReviewerID: task.Duties.ReviewerUserID, ApproverID: task.Duties.ApproverUserID, + }) + if task.TaskKey == taskKey { + stageKey = stage.StageKey + } + } + } + if stageKey == "" { + return "", 0, nil, fmt.Errorf("文档任务 %s 不在当前工作流规划中", taskKey) + } + if instanceID == 0 { + return "", 0, nil, fmt.Errorf("项目尚无激活工作流实例") + } + return stageKey, instanceID, seats, nil +} + +func normalizeTaskKey(projectID int64, ref string) string { + ref = strings.TrimSpace(ref) + prefix := fmt.Sprintf("doc_proj%d_", projectID) + if strings.HasPrefix(ref, prefix) { + return strings.TrimPrefix(ref, prefix) + } + return ref +} + +// AdaptDocResolver builds a ListDocResolver from GovernanceEngine. +func AdaptDocResolver(gov *docgov.GovernanceEngine) coordinator.DocResolver { + if gov == nil { + return nil + } + return &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, + Aliases: []string{r.TaskKey}, + }) + } + return out, nil + }, + } +} + +type pendingSuppAdapter struct{ gov *docgov.GovernanceEngine } + +func (p pendingSuppAdapter) ListPendingSupplements(ctx context.Context, projectID int64, docID string) ([]coordinator.PendingSupplementView, error) { + if p.gov == nil { + return nil, nil + } + rows, err := p.gov.ListPendingSupplementsByProject(ctx, projectID, docID) + if err != nil { + return nil, err + } + out := make([]coordinator.PendingSupplementView, 0, len(rows)) + for _, r := range rows { + title := r.DocID + if doc, derr := p.gov.GetDocument(ctx, r.DocID); derr == nil && doc != nil && doc.Title != "" { + title = doc.Title + } + created := "" + if !r.CreatedAt.IsZero() { + created = r.CreatedAt.Format(time.RFC3339) + } + out = append(out, coordinator.PendingSupplementView{ + DocID: r.DocID, DocTitle: title, SourceSummary: r.SourceSummary, + ImpactReason: r.ImpactReason, CreatedAt: created, + }) + } + return out, nil +} + +// WireDocDialogueTriggers injects DocResolver / pending supplements / reopen ports onto the coordinator and returns a chat ReopenService. +func (r *Runtime) WireDocDialogueTriggers() chat.ReopenService { + if r == nil || r.Engine == nil { + return nil + } + r.Engine.SetDocResolver(AdaptDocResolver(r.Gov)) + r.Engine.SetPendingSupplementReader(pendingSuppAdapter{gov: r.Gov}) + adapter := &chatReopenAdapter{trace: r.Trace, gov: r.Gov, inst: r.Inst, pw: r.PW} + r.Engine.SetReopenLister(coordReopenLister{inner: adapter}) + return adapter +} diff --git a/internal/app/docnotify/lifecycle.go b/internal/app/docnotify/lifecycle.go index c152bf87..efc93344 100644 --- a/internal/app/docnotify/lifecycle.go +++ b/internal/app/docnotify/lifecycle.go @@ -54,15 +54,15 @@ func RecipientForDocEvent(doc *docgov.DocumentRecord, eventType string) int64 { // EmitLifecycleParams carries optional chat/SSE side channels. type EmitLifecycleParams struct { - Notify notificationEmitter - Digest digestUpserter - Chat *ctxmgr.ContextManager - Streamer *stream.Streamer - Action LifecycleAction - Doc *docgov.DocumentRecord - ActorID int64 + Notify notificationEmitter + Digest digestUpserter + Chat *ctxmgr.ContextManager + Streamer *stream.Streamer + Action LifecycleAction + Doc *docgov.DocumentRecord + ActorID int64 ActorName string - Detail string + Detail string } type digestUpserter interface { diff --git a/internal/app/intentflow/expansion.go b/internal/app/intentflow/expansion.go index 81afbeb5..2fa4633a 100644 --- a/internal/app/intentflow/expansion.go +++ b/internal/app/intentflow/expansion.go @@ -193,13 +193,18 @@ var mutateActionKeys = map[string]struct{}{ } var docGovActionKeys = map[string]struct{}{ - "document.claim": {}, - "document.review.submit": {}, - "document.review.approve": {}, - "document.review.reject": {}, - "document.final.approve": {}, - "document.final.reject": {}, - "document.proposal.decide": {}, + "document.claim": {}, + "document.review.submit": {}, + "document.review.approve": {}, + "document.review.reject": {}, + "document.final.approve": {}, + "document.final.reject": {}, + "document.proposal.decide": {}, + "document.comment.add": {}, + "document.comment.resolve": {}, + "document.reopen.request": {}, + "document.reopen.approve": {}, + "document.pending_supplements.query": {}, } var runControlActionKeys = map[string]struct{}{ @@ -212,10 +217,17 @@ var runControlActionKeys = map[string]struct{}{ // DocGovLiveActionKeys are the U11 keys wired to the real docgov engine. // 基线键(claim / review.submit)随 U11 总 flag 放量;P8 逐键键(review approve/reject、 // final.*、proposal.decide)须额外打开对应 LIVE_* flag 才进入 live 集合。 +// 2026-09-25 三权流转对话触发键(comment/reopen/pending)随基线集合接入, +// 同样受 U11 总 flag 控制。 func DocGovLiveActionKeys() map[string]struct{} { keys := map[string]struct{}{ - "document.claim": {}, - "document.review.submit": {}, + "document.claim": {}, + "document.review.submit": {}, + "document.comment.add": {}, + "document.comment.resolve": {}, + "document.reopen.request": {}, + "document.reopen.approve": {}, + "document.pending_supplements.query": {}, } f := CurrentFeatureFlags() if f.U11LiveReview { diff --git a/internal/app/wire_supplement_impact.go b/internal/app/wire_supplement_impact.go index 0ffdbb6d..5d77b89b 100644 --- a/internal/app/wire_supplement_impact.go +++ b/internal/app/wire_supplement_impact.go @@ -44,7 +44,7 @@ func (r *Runtime) wireSupplementImpact() { _ = r.Notify.UpsertDocumentDigest(ctx, notification.UpsertDigestParams{ UserID: doc.AuthorID, ProjectID: doc.ProjectID, DigestKind: notification.DigestKindPendingSupplements, - DocID: doc.DocID, DocTitle: doc.Title, + DocID: doc.DocID, DocTitle: doc.Title, Severity: notification.SeverityActionRequired, Summary: summary, }) diff --git a/internal/chat/action_executor.go b/internal/chat/action_executor.go index c92f54e8..01c3eb86 100644 --- a/internal/chat/action_executor.go +++ b/internal/chat/action_executor.go @@ -42,6 +42,7 @@ type ActionServices struct { TaskRuns TaskStarter IntentPlans IntentPlans Workflow WorkflowRunner + Reopens ReopenService Environment string } @@ -93,6 +94,32 @@ func (s *Session) EnsureDocResolver() { }) } +func mapCommentType(raw string) vocabulary.CommentType { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "issue", "problem", "comment": + return vocabulary.CommentGeneral + case "approval", "approve": + return vocabulary.CommentSuggestion + case "reject", "rejection": + return vocabulary.CommentRejection + default: + return vocabulary.CommentSuggestion + } +} + +func mapUserRole(raw string) vocabulary.UserRole { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "reviewer": + return vocabulary.RoleReviewer + case "approver": + return vocabulary.RoleApprover + case "system": + return vocabulary.RoleSystem + default: + return vocabulary.RoleAuthor + } +} + 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") @@ -215,6 +242,83 @@ func (s *Session) executeAction(ctx context.Context, action *coordinator.Pending } return &ActionExecResult{RunID: created.RunID, Message: "工作流已启动"}, nil + case "docgov.comment_add": + if s.Actions.DocGov == nil { + return nil, fmt.Errorf("docgov engine not available") + } + role := mapUserRole(action.Role) + content := action.Comment + if content == "" { + return nil, fmt.Errorf("批注内容不能为空") + } + cType := mapCommentType(action.Reason) + _, err := s.Actions.DocGov.AddComment(ctx, action.DocID, uid, name, role, action.Section, cType, content) + if err != nil { + return nil, err + } + doc, _ := s.Actions.DocGov.GetDocument(ctx, action.DocID) + state := "" + if doc != nil { + state = string(doc.CurrentState) + } + return &ActionExecResult{DocState: state, Message: "批注已添加"}, nil + + case "docgov.comment_resolve": + if s.Actions.DocGov == nil { + return nil, fmt.Errorf("docgov engine not available") + } + role := mapUserRole(action.Role) + comms, err := s.Actions.DocGov.ListComments(ctx, action.DocID) + if err != nil { + return nil, err + } + var target *docgov.DocumentComment + for _, c := range comms { + if c == nil || c.Status != "open" { + continue + } + if action.Section != "" && c.TargetSection != action.Section { + continue + } + target = c + break + } + if target == nil { + return nil, fmt.Errorf("未找到可解决的未关闭批注") + } + _, err = s.Actions.DocGov.ResolveComment(ctx, target.ID, uid, role, true) + if err != nil { + return nil, err + } + return &ActionExecResult{Message: fmt.Sprintf("已解决批注 #%d", target.ID)}, nil + + case "docgov.reopen_request": + if s.Actions.Reopens == nil { + return nil, fmt.Errorf("reopen service not available") + } + taskKey := action.TaskKey + if taskKey == "" { + taskKey = action.DocID + } + view, err := s.Actions.Reopens.RequestReopenByDoc(ctx, s.ProjectID, taskKey, action.Reason, uid) + if err != nil { + return nil, err + } + return &ActionExecResult{Message: fmt.Sprintf("重开申请已提交(#%d,状态:%s)", view.ID, view.Status)}, nil + + case "docgov.reopen_approve": + if s.Actions.Reopens == nil { + return nil, fmt.Errorf("reopen service not available") + } + if action.RequestID <= 0 { + return nil, fmt.Errorf("缺少重开申请编号") + } + view, err := s.Actions.Reopens.ApproveReopenByID(ctx, s.ProjectID, action.RequestID, uid) + if err != nil { + return nil, err + } + return &ActionExecResult{Message: fmt.Sprintf("已批准重开申请 #%d", view.ID)}, nil + case "taskrun.resume", "taskrun.restart": if s.Actions.TaskRuns == nil { return nil, fmt.Errorf("task run services not available") @@ -278,6 +382,23 @@ func renderActionReply(action *coordinator.PendingAction, result *ActionExecResu return fmt.Sprintf("已确认执行驳回操作!《%s》已退回(当前状态:%s),驳回意见已同步下发。", docName, result.DocState) case "workflow.run": return fmt.Sprintf("工作流已启动(run_id=%s)。您可在工作流面板查看进度。", result.RunID) + case "docgov.comment_add": + return fmt.Sprintf("已添加批注到《%s》。", docName) + case "docgov.comment_resolve": + if result.Message != "" { + return result.Message + "。" + } + return fmt.Sprintf("已解决《%s》的批注。", docName) + case "docgov.reopen_request": + if result.Message != "" { + return result.Message + "。" + } + return fmt.Sprintf("已提交《%s》的重开申请。", docName) + case "docgov.reopen_approve": + if result.Message != "" { + return result.Message + "。" + } + return "已批准重开申请。" case "taskrun.resume": return fmt.Sprintf("续跑任务已启动(run_id=%s)。您可在任务面板查看进度。", result.RunID) case "taskrun.restart": diff --git a/internal/chat/action_executor_test.go b/internal/chat/action_executor_test.go index 673c4b91..0a97028e 100644 --- a/internal/chat/action_executor_test.go +++ b/internal/chat/action_executor_test.go @@ -2,6 +2,8 @@ package chat import ( "context" + "fmt" + "strings" "testing" "chat-agent-system/internal/bizdev" @@ -28,7 +30,7 @@ func TestExecuteAction_DocGovReviewApprove(t *testing.T) { _, _ = docGov.SubmitForReview(ctx, docID, 1, docgov.RoleAuthor, "张工") sess := &Session{ - UserID: 2, + UserID: 2, Actions: &ActionServices{DocGov: docGov}, } action := &coordinator.PendingAction{ @@ -52,3 +54,107 @@ func TestExecuteAction_DocGovReviewApprove(t *testing.T) { t.Fatal("expected non-empty reply") } } + +func TestExecuteAction_SubmitReviewAndComment(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" + _, err := docGov.CreateOrGetDocument( + ctx, docID, 1, "intake", "需求收集表", "V0.1", "", + 1, "张工", 2, "李主管", 3, "王总监", + ) + if err != nil { + t.Fatalf("create: %v", err) + } + _ = docGov.MarkDocumentContentUpdated(ctx, docID, "hash1", false) + + sess := &Session{UserID: 1, ProjectID: 1, Actions: &ActionServices{DocGov: docGov}} + userCtx := &coordinator.UserProjectContext{User: coordinator.UserInfo{ID: "1", Name: "张工"}} + + res, err := sess.executeAction(ctx, &coordinator.PendingAction{Kind: "docgov.submit_review", DocID: docID}, userCtx) + if err != nil { + t.Fatalf("submit: %v", err) + } + if res.DocState != string(docgov.StateReviewing) { + t.Fatalf("state=%s", res.DocState) + } + + _, err = sess.executeAction(ctx, &coordinator.PendingAction{ + Kind: "docgov.comment_add", DocID: docID, Role: "reviewer", Section: "s1", + Comment: "请补充风险", Reason: "suggestion", + }, &coordinator.UserProjectContext{User: coordinator.UserInfo{ID: "2", Name: "李主管"}}) + if err != nil { + // author can also comment in reviewing; use reviewer id + t.Fatalf("comment add: %v", err) + } + + sess.UserID = 2 + _, err = sess.executeAction(ctx, &coordinator.PendingAction{ + Kind: "docgov.comment_resolve", DocID: docID, Role: "reviewer", Section: "s1", + }, &coordinator.UserProjectContext{User: coordinator.UserInfo{ID: "2", Name: "李主管"}}) + if err != nil { + t.Fatalf("comment resolve: %v", err) + } +} + +type stubReopen struct { + reqErr error + aprErr error +} + +func (s *stubReopen) ListPendingReopens(context.Context, int64) ([]ReopenRequestView, error) { + return nil, nil +} +func (s *stubReopen) RequestReopenByDoc(context.Context, int64, string, string, int64) (ReopenRequestView, error) { + if s.reqErr != nil { + return ReopenRequestView{}, s.reqErr + } + return ReopenRequestView{ID: 9, Status: "pending"}, nil +} +func (s *stubReopen) ApproveReopenByID(context.Context, int64, int64, int64) (ReopenRequestView, error) { + if s.aprErr != nil { + return ReopenRequestView{}, s.aprErr + } + return ReopenRequestView{ID: 9, Status: "approved"}, nil +} + +func TestExecuteAction_ReopenPaths(t *testing.T) { + sess := &Session{ + UserID: 1, ProjectID: 1, + Actions: &ActionServices{Reopens: &stubReopen{}}, + } + userCtx := &coordinator.UserProjectContext{User: coordinator.UserInfo{ID: "1", Name: "A"}} + res, err := sess.executeAction(context.Background(), &coordinator.PendingAction{ + Kind: "docgov.reopen_request", TaskKey: "intake", Reason: "需补充", + }, userCtx) + if err != nil { + t.Fatal(err) + } + if res.Message == "" { + t.Fatal("expected message") + } + res, err = sess.executeAction(context.Background(), &coordinator.PendingAction{ + Kind: "docgov.reopen_approve", RequestID: 9, + }, userCtx) + if err != nil { + t.Fatal(err) + } + if res.Message == "" { + t.Fatal("expected approve message") + } + + sess.Actions.Reopens = &stubReopen{reqErr: fmt.Errorf("无权申请重开")} + _, err = sess.executeAction(context.Background(), &coordinator.PendingAction{ + Kind: "docgov.reopen_request", TaskKey: "intake", Reason: "x", + }, userCtx) + if err == nil { + t.Fatal("expected permission failure") + } + reply := renderActionReply(&coordinator.PendingAction{Kind: "docgov.reopen_request", DocID: "d"}, nil, err) + if reply == "" || !strings.Contains(reply, "无权") { + t.Fatalf("reply=%s", reply) + } +} diff --git a/internal/chat/ports.go b/internal/chat/ports.go index 864ccb6f..5acd651f 100644 --- a/internal/chat/ports.go +++ b/internal/chat/ports.go @@ -80,3 +80,18 @@ type TaskStartRequest struct { ProjectID int64 SessionID string } + +// ReopenRequestView is the chat-facing projection of a stage reopen request. +type ReopenRequestView struct { + ID int64 + DocTasks []string + Reason string + Status string +} + +// ReopenService is the chat port for Approver-first document reopen. +type ReopenService interface { + ListPendingReopens(ctx context.Context, projectID int64) ([]ReopenRequestView, error) + RequestReopenByDoc(ctx context.Context, projectID int64, taskKey, reason string, userID int64) (ReopenRequestView, error) + ApproveReopenByID(ctx context.Context, projectID, requestID, userID int64) (ReopenRequestView, error) +} diff --git a/internal/coordinator/confirm_gate_test.go b/internal/coordinator/confirm_gate_test.go index 307b51fb..e9ea32f1 100644 --- a/internal/coordinator/confirm_gate_test.go +++ b/internal/coordinator/confirm_gate_test.go @@ -20,7 +20,7 @@ func TestConfirmGate_WeakVerbNeedsConfirmation(t *testing.T) { Confidence: 0.78, Branch: intent.BranchNewIntentPreempt, FilledSlots: map[string]string{ - intent.SlotTargetTask: "intake", + intent.SlotTargetTask: "intake", intent.SlotNeedsConfirmation: "true", }, } diff --git a/internal/coordinator/dialogue_llm_test.go b/internal/coordinator/dialogue_llm_test.go index f5a625b0..77fa4034 100644 --- a/internal/coordinator/dialogue_llm_test.go +++ b/internal/coordinator/dialogue_llm_test.go @@ -394,7 +394,6 @@ func TestDialogueLLM_AmbiguousSmalltalkDoesNotUseStageSalesTemplate(t *testing.T } } - type stubRealtimeSearcher struct { markdown string err error diff --git a/internal/coordinator/docgov_dialogue_test.go b/internal/coordinator/docgov_dialogue_test.go new file mode 100644 index 00000000..e1e09c26 --- /dev/null +++ b/internal/coordinator/docgov_dialogue_test.go @@ -0,0 +1,168 @@ +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 TestListDocResolver_ZeroAndMultiMatchClarify(t *testing.T) { + r := &coordinator.ListDocResolver{ + List: func(context.Context, int64) ([]coordinator.DocListItem, error) { + return []coordinator.DocListItem{ + {DocID: "d1", Title: "需求收集表", State: vocabulary.StateDrafting, TaskKey: "intake"}, + {DocID: "d2", Title: "需求评估表", State: vocabulary.StateDrafting, TaskKey: "eval"}, + }, nil + }, + } + _, ok, cands, err := r.ResolveDoc(context.Background(), 1, "需求", "") + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("expected multi-match not ok") + } + if len(cands) < 2 { + t.Fatalf("expected candidates, got %v", cands) + } + + _, ok, cands, err = r.ResolveDoc(context.Background(), 1, "不存在的文档XYZ", "") + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("expected zero unique match") + } + if len(cands) == 0 { + t.Fatal("expected inventory candidates on zero match") + } + + doc, ok, _, err := r.ResolveDoc(context.Background(), 1, "需求收集表", "") + if err != nil || !ok || doc == nil || doc.DocID != "d1" { + t.Fatalf("unique title match failed: ok=%v doc=%+v err=%v", ok, doc, err) + } + + doc, ok, _, err = r.ResolveDoc(context.Background(), 1, "@active", "d2") + if err != nil || !ok || doc.DocID != "d2" { + t.Fatalf("active resolve failed: %+v", doc) + } +} + +func TestHandleDocGovSubmitReview_ConfirmationCard(t *testing.T) { + engine := coordinator.NewCoordinatorEngine(nil) + engine.SetDocResolver(&stubDocResolver{ + doc: &coordinator.ResolvedDoc{DocID: "doc-001", Title: "需求收集表", State: vocabulary.StateDrafting, TaskKey: "intake"}, + ok: true, + }) + userCtx := &coordinator.UserProjectContext{ + User: coordinator.UserInfo{ID: "1", Name: "张工", Role: "author", ProjectRole: "author"}, + Project: coordinator.ProjectInfo{ID: "10", Name: "测试项目"}, + } + res, err := engine.ProcessIntent(context.Background(), &intent.IntentRecognitionResult{ + TopIntent: &intent.CandidateIntent{ + Intent: intent.IntentDocGovSubmitReview, + FilledSlots: map[string]string{intent.SlotTargetDoc: "需求收集表"}, + }, + }, userCtx) + if err != nil { + t.Fatal(err) + } + if res.Status != coordinator.StatusAccepted { + t.Fatalf("status=%s", res.Status) + } + if res.InteractiveCard == nil || res.InteractiveCard.CardType != "CONFIRMATION" { + t.Fatalf("expected CONFIRMATION card, got %+v", res.InteractiveCard) + } + if res.PendingAction != nil { + t.Fatal("must not execute before confirm") + } + + // confirm + res2, err := engine.ProcessIntent(context.Background(), &intent.IntentRecognitionResult{ + TopIntent: &intent.CandidateIntent{ + Intent: intent.IntentDocGovProposalDecide, + FilledSlots: map[string]string{ + "target_action": "confirm_submit_review", + "target_doc": "doc-001", + }, + }, + }, userCtx) + if err != nil { + t.Fatal(err) + } + if res2.PendingAction == nil || res2.PendingAction.Kind != "docgov.submit_review" { + t.Fatalf("expected submit_review pending, got %+v", res2.PendingAction) + } + if res2.PendingAction.DocID != "doc-001" { + t.Fatalf("doc id=%s", res2.PendingAction.DocID) + } +} + +func TestHandleDocGovCommentAdd_ClarifyOnMultiDoc(t *testing.T) { + engine := coordinator.NewCoordinatorEngine(nil) + engine.SetDocResolver(&stubDocResolver{ + ok: false, + candidates: []string{"需求收集表(d1)", "需求评估表(d2)"}, + }) + userCtx := &coordinator.UserProjectContext{ + User: coordinator.UserInfo{ID: "1", Role: "author", ProjectRole: "author"}, + Project: coordinator.ProjectInfo{ID: "10"}, + } + res, err := engine.ProcessIntent(context.Background(), &intent.IntentRecognitionResult{ + TopIntent: &intent.CandidateIntent{ + Intent: intent.IntentDocGovCommentAdd, + FilledSlots: map[string]string{ + intent.SlotTargetDoc: "需求", + intent.SlotCommentContent: "缺风险分析", + }, + }, + }, userCtx) + if err != nil { + t.Fatal(err) + } + if res.Status != coordinator.StatusClarificationRequired { + t.Fatalf("expected clarification, got %s", res.Status) + } + if !strings.Contains(res.NaturalReply, "需求收集表") { + t.Fatalf("reply should list candidates: %s", res.NaturalReply) + } +} + +func TestHandleQueryPendingSupplements(t *testing.T) { + engine := coordinator.NewCoordinatorEngine(nil) + engine.SetPendingSupplementReader(&stubPendingReader{rows: []coordinator.PendingSupplementView{ + {DocID: "d1", DocTitle: "需求表", SourceSummary: "预算改为200万", ImpactReason: "锁定中"}, + }}) + res, err := engine.ProcessIntent(context.Background(), &intent.IntentRecognitionResult{ + TopIntent: &intent.CandidateIntent{Intent: intent.IntentQueryPendingSupplements}, + }, &coordinator.UserProjectContext{Project: coordinator.ProjectInfo{ID: "1"}}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(res.NaturalReply, "预算改为200万") { + t.Fatalf("reply=%s", res.NaturalReply) + } +} + +type stubPendingReader struct { + rows []coordinator.PendingSupplementView +} + +func (s *stubPendingReader) ListPendingSupplements(context.Context, int64, string) ([]coordinator.PendingSupplementView, error) { + return s.rows, nil +} diff --git a/internal/coordinator/engine.go b/internal/coordinator/engine.go index 470e8171..8930fece 100644 --- a/internal/coordinator/engine.go +++ b/internal/coordinator/engine.go @@ -27,15 +27,18 @@ const ( // PendingAction 声明对话层需执行的真实动作(由 Session 持有服务后执行)。 type PendingAction struct { - 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 + Kind string // docgov.claim | docgov.submit_review | docgov.review_approve | docgov.approver_approve | docgov.reject | docgov.comment_add | docgov.comment_resolve | docgov.reopen_request | docgov.reopen_approve | workflow.run | taskrun.resume | taskrun.restart + DocID string + WorkflowKey string + RunID string + WorkDir string + Reason string + Comment string + Rollback vocabulary.DocumentState + TaskKey string + Role string + Section string + RequestID int64 } // CoordinationResult 协调层给对话层的回执 @@ -77,11 +80,13 @@ type CoordinatorEngine struct { kbClient *kb.Client impact ImpactActions projectAccess ProjectAccess + docResolver DocResolver + pendingSupps PendingSupplementReader + reopenLister ReopenLister // OnSupplement is optional; composition typically wires supplementimpact.TriggerAsync. // Nil-safe: unset hooks are skipped. - OnSupplement func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string) + 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. @@ -117,7 +122,6 @@ func (e *CoordinatorEngine) SetInstanceService(svc *projectinstance.Service) { } } - // SetRealtimeSearcher 注入对话路径的实时检索(天气/新闻等);nil 时跳过强制检索。 func (e *CoordinatorEngine) SetRealtimeSearcher(s RealtimeSearcher) { if e != nil { @@ -132,6 +136,27 @@ func (e *CoordinatorEngine) SetImpactActions(a ImpactActions) { } } +// SetDocResolver wires the controlled-document resolution gate. +func (e *CoordinatorEngine) SetDocResolver(r DocResolver) { + if e != nil { + e.docResolver = r + } +} + +// SetPendingSupplementReader wires locked-period pending supplement queries. +func (e *CoordinatorEngine) SetPendingSupplementReader(r PendingSupplementReader) { + if e != nil { + e.pendingSupps = r + } +} + +// SetReopenLister wires pending reopen listing for approve dialogue. +func (e *CoordinatorEngine) SetReopenLister(r ReopenLister) { + if e != nil { + e.reopenLister = r + } +} + // SetOnSupplement wires the optional post-blackboard supplement impact hook. func (e *CoordinatorEngine) SetOnSupplement(fn func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string)) { if e != nil { @@ -139,13 +164,6 @@ 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 @@ -321,6 +339,21 @@ func (e *CoordinatorEngine) ProcessIntent( case intent.IntentDocGovSubmitReview: coordRes, coordErr = e.handleDocGovSubmitReview(ctx, top, userCtx) + case intent.IntentDocGovCommentAdd: + coordRes, coordErr = e.handleDocGovCommentAdd(ctx, top, userCtx) + + case intent.IntentDocGovCommentResolve: + coordRes, coordErr = e.handleDocGovCommentResolve(ctx, top, userCtx) + + case intent.IntentDocGovReopenRequest: + coordRes, coordErr = e.handleDocGovReopenRequest(ctx, top, userCtx) + + case intent.IntentDocGovReopenApprove: + coordRes, coordErr = e.handleDocGovReopenApprove(ctx, top, userCtx) + + case intent.IntentQueryPendingSupplements: + coordRes, coordErr = e.handleQueryPendingSupplements(ctx, top, userCtx) + case intent.IntentDocGovProposalDecide: coordRes, coordErr = e.handleDocGovProposalDecide(ctx, top, userCtx) diff --git a/internal/coordinator/engine_docgov.go b/internal/coordinator/engine_docgov.go index 7b16ecc9..86a04460 100644 --- a/internal/coordinator/engine_docgov.go +++ b/internal/coordinator/engine_docgov.go @@ -4,6 +4,7 @@ import ( "chat-agent-system/internal/contracts/vocabulary" "context" "fmt" + "strconv" "strings" "time" @@ -275,7 +276,7 @@ func (e *CoordinatorEngine) handleDocGovClaim(ctx context.Context, cand *intent. }, nil } -// handleDocGovSubmitReview 解析门 → drafting 校验 → 确认卡 → 拍板后 PendingAction +// handleDocGovSubmitReview 解析门 → drafting 校验 → CONFIRMATION 卡(含锁定后果)→ 拍板后 PendingAction func (e *CoordinatorEngine) handleDocGovSubmitReview(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { doc, gate := e.resolveTargetDocGate(ctx, cand, userCtx) if gate != nil { @@ -300,7 +301,7 @@ func (e *CoordinatorEngine) handleDocGovSubmitReview(ctx context.Context, cand * "受控文档送审确认", doc.DocID, title, "", "编制完成,提交审核并锁定正文", - "送审后文档进入审核锁定:非审核职责人不可改正文。", + "送审后文档进入审核锁定:非审核职责人不可改正文;锁定期间补充将进入积压台账,待退回编制后再消化。", "confirm_submit_review", "确认送审", map[string]string{"task_key": doc.TaskKey}, ) @@ -455,6 +456,85 @@ func (e *CoordinatorEngine) handleDocGovProposalDecide(ctx context.Context, cand }, nil } + if targetAction == "confirm_comment_add" { + 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.comment_add", + DocID: docName, + Role: payload["role"], + Section: payload["target_section"], + Comment: payload["comment_content"], + Reason: payload["comment_type"], + }, + }, nil + } + + if targetAction == "confirm_comment_resolve" { + 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.comment_resolve", + DocID: docName, + Role: payload["role"], + Section: payload["target_section"], + }, + }, nil + } + + if targetAction == "confirm_reopen_request" { + 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.reopen_request", + DocID: docName, + TaskKey: payload["task_key"], + Reason: payload["reopen_reason"], + Role: payload["role"], + }, + }, nil + } + + if targetAction == "confirm_reopen_approve" { + payload := mergeDecidePayload(cand, userCtx) + if userCtx.InteractiveCard != nil { + userCtx.dismissCard("resolved") + } + userCtx.applyPending(nil) + reqID, _ := strconv.ParseInt(payload["request_id"], 10, 64) + return &CoordinationResult{ + Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, + NaturalReply: "正在批准重开申请…", + UpdatedContext: userCtx, ClearExpectation: true, + PendingAction: &PendingAction{ + Kind: "docgov.reopen_approve", + DocID: docName, + RequestID: reqID, + }, + }, nil + } + if targetAction == "cancel" { if userCtx.InteractiveCard != nil { userCtx.dismissCard("cancelled") diff --git a/internal/coordinator/engine_docgov_comments.go b/internal/coordinator/engine_docgov_comments.go new file mode 100644 index 00000000..c5082793 --- /dev/null +++ b/internal/coordinator/engine_docgov_comments.go @@ -0,0 +1,107 @@ +package coordinator + +import ( + "context" + "fmt" + "strings" + + "chat-agent-system/internal/contracts/vocabulary" + "chat-agent-system/internal/intent" +) + +// handleDocGovCommentAdd 解析门 → 批注矩阵剪枝 → CONFIRMATION 卡(含内容预览) +func (e *CoordinatorEngine) handleDocGovCommentAdd(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { + doc, gate := e.resolveTargetDocGate(ctx, cand, userCtx) + if gate != nil { + return gate, nil + } + content := strings.TrimSpace(cand.FilledSlots[intent.SlotCommentContent]) + section := strings.TrimSpace(cand.FilledSlots[intent.SlotTargetSection]) + cType := strings.TrimSpace(cand.FilledSlots[intent.SlotCommentType]) + if cType == "" { + cType = "suggestion" + } + if content == "" { + return &CoordinationResult{ + Status: StatusClarificationRequired, + Intent: cand.Intent, + Branch: cand.Branch, + ClarificationPrompt: "请补充批注内容后再确认添加。", + NaturalReply: "已定位文档,但缺少批注正文。请说明要添加的意见内容。", + UpdatedContext: userCtx, + }, nil + } + + role := effectiveDocRole(userCtx) + if userCtx != nil && userCtx.ToSnapshot() != nil && userCtx.ToSnapshot().UserDocRole != "" { + role = userCtx.ToSnapshot().UserDocRole + } + state := doc.State + if state == "" && userCtx != nil && userCtx.ToSnapshot() != nil { + state = vocabulary.DocumentState(userCtx.ToSnapshot().DocLifecycleState) + } + if state != "" && role != "" && !commentRoleAllowed(role, state) { + return &CoordinationResult{ + Status: StatusRejected, + Intent: cand.Intent, + Branch: cand.Branch, + RejectReason: "COMMENT_MATRIX_DENIED", + NaturalReply: fmt.Sprintf("按批注矩阵,角色「%s」在「%s」阶段不可添加批注。", role, state), + UpdatedContext: userCtx, + }, nil + } + + title := displayDocTitle(doc) + preview := content + if section != "" { + preview = fmt.Sprintf("[%s] %s", section, content) + } + card, exp := buildConfirmCard( + "添加受控文档批注确认", + doc.DocID, title, section, preview, + "确认后将写入文档批注;服务端会按角色×阶段矩阵二次校验。", + "confirm_comment_add", "确认添加批注", + map[string]string{ + "role": role, + "comment_content": content, + "comment_type": cType, + "target_section": section, + }, + ) + userCtx.applyCard(card) + userCtx.applyPending(exp) + reply := fmt.Sprintf("已为《%s》起草批注确认单。预览:%s。请确认或回复「确认」。", title, preview) + return &CoordinationResult{ + Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, + NaturalReply: reply, InteractiveCard: card, NextExpectation: exp, UpdatedContext: userCtx, + }, nil +} + +// handleDocGovCommentResolve 解析门 → CONFIRMATION 卡 → PendingAction +func (e *CoordinatorEngine) handleDocGovCommentResolve(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { + doc, gate := e.resolveTargetDocGate(ctx, cand, userCtx) + if gate != nil { + return gate, nil + } + section := strings.TrimSpace(cand.FilledSlots[intent.SlotTargetSection]) + role := effectiveDocRole(userCtx) + title := displayDocTitle(doc) + proposed := "将匹配的未解决批注标记为已处理" + if section != "" { + proposed = fmt.Sprintf("将章节「%s」下未解决批注标记为已处理", section) + } + card, exp := buildConfirmCard( + "解决受控文档批注确认", + doc.DocID, title, section, proposed, + "确认后由服务端按角色权限二次校验并更新批注状态。", + "confirm_comment_resolve", "确认解决批注", + map[string]string{"role": role, "target_section": section}, + ) + userCtx.applyCard(card) + userCtx.applyPending(exp) + return &CoordinationResult{ + Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, + NaturalReply: fmt.Sprintf("已为《%s》起草解决批注确认单。请确认后执行。", title), + InteractiveCard: card, NextExpectation: exp, UpdatedContext: userCtx, + }, nil +} diff --git a/internal/coordinator/engine_docgov_reopen.go b/internal/coordinator/engine_docgov_reopen.go new file mode 100644 index 00000000..8a28972d --- /dev/null +++ b/internal/coordinator/engine_docgov_reopen.go @@ -0,0 +1,201 @@ +package coordinator + +import ( + "context" + "fmt" + "strconv" + "strings" + + "chat-agent-system/internal/contracts/vocabulary" + "chat-agent-system/internal/intent" +) + +// handleDocGovReopenRequest 须 approved;卡片含原因 → PendingAction reopen_request +func (e *CoordinatorEngine) handleDocGovReopenRequest(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { + doc, gate := e.resolveTargetDocGate(ctx, cand, userCtx) + if gate != nil { + return gate, nil + } + reason := strings.TrimSpace(cand.FilledSlots[intent.SlotReopenReason]) + if reason == "" { + return &CoordinationResult{ + Status: StatusClarificationRequired, + Intent: cand.Intent, + Branch: cand.Branch, + ClarificationPrompt: "请补充重开原因后再申请。", + NaturalReply: "已定位文档,但缺少重开原因。请说明为何需要再修订。", + UpdatedContext: userCtx, + }, nil + } + state := doc.State + if state == "" && userCtx != nil && userCtx.ToSnapshot() != nil { + state = vocabulary.DocumentState(userCtx.ToSnapshot().DocLifecycleState) + } + if state != "" && state != vocabulary.StateApproved { + return &CoordinationResult{ + Status: StatusRejected, + Intent: cand.Intent, + Branch: cand.Branch, + RejectReason: "REOPEN_STATE_DENIED", + NaturalReply: fmt.Sprintf("当前文档处于「%s」阶段,仅已批准(approved)文档可申请重开。", state), + UpdatedContext: userCtx, + }, nil + } + role := effectiveDocRole(userCtx) + title := displayDocTitle(doc) + card, exp := buildConfirmCard( + "申请受控文档重开确认", + doc.DocID, title, "", reason, + "确认后将提交重开申请;批准人优先审批(创建人兜底),服务端二次校验职责权限。", + "confirm_reopen_request", "确认申请重开", + map[string]string{ + "role": role, + "reopen_reason": reason, + "task_key": doc.TaskKey, + }, + ) + userCtx.applyCard(card) + userCtx.applyPending(exp) + return &CoordinationResult{ + Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, + NaturalReply: fmt.Sprintf("已为《%s》起草重开申请确认单。原因:%s。请确认或回复「确认」。", title, reason), + InteractiveCard: card, NextExpectation: exp, UpdatedContext: userCtx, + }, nil +} + +// handleDocGovReopenApprove 列 pending 重开申请 → 卡片 → PendingAction +func (e *CoordinatorEngine) handleDocGovReopenApprove(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { + projectID := int64(0) + if userCtx != nil { + projectID = parseDecimalID(userCtx.Project.ID) + } + var items []ReopenListItem + if e != nil && e.reopenLister != nil { + var err error + items, err = e.reopenLister.ListPendingReopens(ctx, projectID) + if err != nil { + return &CoordinationResult{ + Status: StatusRejected, Intent: cand.Intent, Branch: cand.Branch, + RejectReason: "REOPEN_LIST_FAILED", NaturalReply: fmt.Sprintf("无法列出待批重开申请:%v", err), + UpdatedContext: userCtx, + }, nil + } + } + reqID := int64(0) + if cand.FilledSlots != nil { + if raw := strings.TrimSpace(cand.FilledSlots["request_id"]); raw != "" { + reqID, _ = strconv.ParseInt(raw, 10, 64) + } + } + if reqID == 0 && len(items) == 1 { + reqID = items[0].RequestID + } + if reqID == 0 { + if len(items) == 0 { + return &CoordinationResult{ + Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, + NaturalReply: "当前没有待批准的重开申请。", UpdatedContext: userCtx, + }, nil + } + var b strings.Builder + b.WriteString("请指定要批准的重开申请编号(回复 request_id=N):\n") + for _, it := range items { + b.WriteString(fmt.Sprintf("- #%d 文档任务=%v 原因=%s\n", it.RequestID, it.DocTasks, it.Reason)) + } + return &CoordinationResult{ + Status: StatusClarificationRequired, Intent: cand.Intent, Branch: cand.Branch, + ClarificationPrompt: b.String(), NaturalReply: b.String(), UpdatedContext: userCtx, + }, nil + } + + reason := "" + docID := "" + for _, it := range items { + if it.RequestID == reqID { + reason = it.Reason + if len(it.DocTasks) > 0 { + docID = it.DocTasks[0] + } + break + } + } + if docID == "" { + doc, gate := e.resolveTargetDocGate(ctx, cand, userCtx) + if gate != nil && doc == nil { + // still allow approve by request id alone + docID = fmt.Sprintf("reopen#%d", reqID) + } else if doc != nil { + docID = doc.DocID + } else { + docID = fmt.Sprintf("reopen#%d", reqID) + } + } + title := docID + proposed := fmt.Sprintf("批准重开申请 #%d", reqID) + if reason != "" { + proposed += ":" + reason + } + card, exp := buildConfirmCard( + "批准受控文档重开确认", + docID, title, "", proposed, + "确认后将解锁文档至编制态;服务端按批准人优先/创建人兜底二次校验。", + "confirm_reopen_approve", "确认批准重开", + map[string]string{"request_id": strconv.FormatInt(reqID, 10)}, + ) + userCtx.applyCard(card) + userCtx.applyPending(exp) + return &CoordinationResult{ + Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, + NaturalReply: fmt.Sprintf("已起草批准重开确认单(申请 #%d)。请确认或回复「确认」。", reqID), + InteractiveCard: card, NextExpectation: exp, UpdatedContext: userCtx, + }, nil +} + +// handleQueryPendingSupplements 只读直答积压补充 +func (e *CoordinatorEngine) handleQueryPendingSupplements(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { + projectID := int64(0) + if userCtx != nil { + projectID = parseDecimalID(userCtx.Project.ID) + } + docID := "" + if cand != nil && cand.FilledSlots != nil { + docID = strings.TrimSpace(cand.FilledSlots[intent.SlotTargetDoc]) + } + if e == nil || e.pendingSupps == nil { + return &CoordinationResult{ + Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, + NaturalReply: "积压补充查询尚未接到当前环境。", UpdatedContext: userCtx, + }, nil + } + rows, err := e.pendingSupps.ListPendingSupplements(ctx, projectID, docID) + if err != nil { + return &CoordinationResult{ + Status: StatusRejected, Intent: cand.Intent, Branch: cand.Branch, + RejectReason: "PENDING_SUPPLEMENT_QUERY_FAILED", + NaturalReply: fmt.Sprintf("查询积压补充失败:%v", err), UpdatedContext: userCtx, + }, nil + } + if len(rows) == 0 { + return &CoordinationResult{ + Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, + NaturalReply: "当前没有未消化的锁定期间积压补充。", UpdatedContext: userCtx, + }, nil + } + var b strings.Builder + b.WriteString(fmt.Sprintf("共有 %d 条待消化积压补充:\n", len(rows))) + for i, r := range rows { + title := r.DocTitle + if title == "" { + title = r.DocID + } + b.WriteString(fmt.Sprintf("%d) 《%s》来源:%s", i+1, title, r.SourceSummary)) + if r.ImpactReason != "" { + b.WriteString(";影响:" + r.ImpactReason) + } + b.WriteString("\n") + } + return &CoordinationResult{ + Status: StatusAccepted, Intent: cand.Intent, Branch: cand.Branch, + NaturalReply: b.String(), UpdatedContext: userCtx, + }, nil +} diff --git a/internal/coordinator/engine_test.go b/internal/coordinator/engine_test.go index 322f3129..a92d6e8a 100644 --- a/internal/coordinator/engine_test.go +++ b/internal/coordinator/engine_test.go @@ -526,7 +526,7 @@ func TestCoordinatorEngine_DocGovActions(t *testing.T) { t.Errorf("expected claim confirmation card, reply=%q", resClaim.NaturalReply) } - // 2. DocGovSubmitReview + // 2. DocGovSubmitReview — 两步确认,首轮挂卡不立即执行 recSubmit := &intent.IntentRecognitionResult{ TopIntent: &intent.CandidateIntent{ Intent: intent.IntentDocGovSubmitReview, @@ -548,6 +548,12 @@ func TestCoordinatorEngine_DocGovActions(t *testing.T) { if resSubmit.InteractiveCard == nil { t.Errorf("expected submit confirmation card") } + if resSubmit.InteractiveCard == nil || resSubmit.InteractiveCard.CardType != "CONFIRMATION" { + t.Errorf("expected submit CONFIRMATION card, got %+v", resSubmit.InteractiveCard) + } + if resSubmit.PendingAction != nil { + t.Errorf("submit must not PendingAction before confirm") + } // 3. DocGovApprove reviewerCtx := &coordinator.UserProjectContext{ diff --git a/internal/coordinator/memory_composer_test.go b/internal/coordinator/memory_composer_test.go index 70244f9f..02fb180c 100644 --- a/internal/coordinator/memory_composer_test.go +++ b/internal/coordinator/memory_composer_test.go @@ -134,7 +134,6 @@ func TestMemoryComposer_HierarchyAndPriority(t *testing.T) { } } - func TestMemoryComposer_RealtimeSnippetAndUnavailable(t *testing.T) { composer := coordinator.NewMemoryComposer() fixed := time.Date(2026, 9, 23, 10, 41, 0, 0, time.FixedZone("CST", 8*3600)) diff --git a/internal/coordinator/p0_fake_success_test.go b/internal/coordinator/p0_fake_success_test.go index 9b041097..98a8fc78 100644 --- a/internal/coordinator/p0_fake_success_test.go +++ b/internal/coordinator/p0_fake_success_test.go @@ -106,9 +106,9 @@ func TestHandleDocGovSubmitReview_ConfirmThenPending(t *testing.T) { decide := &intent.CandidateIntent{ Intent: intent.IntentDocGovProposalDecide, FilledSlots: map[string]string{ - "target_action": "confirm_submit_review", - intent.SlotTargetDoc: "doc_proj9_intake", - "task_key": "intake", + "target_action": "confirm_submit_review", + intent.SlotTargetDoc: "doc_proj9_intake", + "task_key": "intake", }, } res2, err := engine.ProcessIntent(context.Background(), &intent.IntentRecognitionResult{TopIntent: decide}, userCtx) diff --git a/internal/coordinator/router.go b/internal/coordinator/router.go index 9681672d..826606eb 100644 --- a/internal/coordinator/router.go +++ b/internal/coordinator/router.go @@ -152,7 +152,9 @@ func RouteIntent(it intent.IntentType, profile TaskProfile) RouteDecision { case intent.IntentSecurityForbidden, intent.IntentDocGovClaim, intent.IntentDocGovSubmitReview, intent.IntentDocGovReviewApprove, intent.IntentDocGovReviewReject, intent.IntentDocGovFinalApprove, intent.IntentDocGovFinalReject, - intent.IntentDocGovProposalDecide, intent.IntentQueryRejectReason, + intent.IntentDocGovProposalDecide, intent.IntentDocGovCommentAdd, intent.IntentDocGovCommentResolve, + intent.IntentDocGovReopenRequest, intent.IntentDocGovReopenApprove, + intent.IntentQueryRejectReason, intent.IntentQueryPendingSupplements, intent.IntentQueryProgress, intent.IntentQueryRegulations, intent.IntentQuerySimilarCases, intent.IntentQueryUserRights, intent.IntentQueryWorkflowStatus, intent.IntentQueryDocStatus, intent.IntentHelpCapabilities, intent.IntentUnsupportedAction, intent.IntentRunStatusQuery, diff --git a/internal/coordinator/router_test.go b/internal/coordinator/router_test.go index 47690836..b7ebbc7c 100644 --- a/internal/coordinator/router_test.go +++ b/internal/coordinator/router_test.go @@ -64,7 +64,8 @@ func TestRouteIntent_R0Equivalence(t *testing.T) { intent.IntentSecurityForbidden, intent.IntentDeclineExpectation, intent.IntentDocGovClaim, intent.IntentDocGovSubmitReview, intent.IntentDocGovReviewApprove, intent.IntentDocGovReviewReject, intent.IntentDocGovFinalApprove, intent.IntentDocGovFinalReject, - intent.IntentDocGovProposalDecide, + intent.IntentDocGovProposalDecide, intent.IntentDocGovCommentAdd, intent.IntentDocGovCommentResolve, + intent.IntentDocGovReopenRequest, intent.IntentDocGovReopenApprove, intent.IntentQueryPendingSupplements, } { if d := RouteIntent(it, TaskProfile{}); d.Channel != ChannelTemplate { t.Errorf("%s should stay TEMPLATE, got %s", it, d.Channel) diff --git a/internal/docgov/engine_pending.go b/internal/docgov/engine_pending.go index 5be11892..b08fb6bc 100644 --- a/internal/docgov/engine_pending.go +++ b/internal/docgov/engine_pending.go @@ -5,6 +5,8 @@ import ( "fmt" "strings" "time" + + "github.com/jackc/pgx/v5" ) const ( @@ -109,6 +111,49 @@ func (e *GovernanceEngine) ListPendingSupplements(ctx context.Context, docID str return out, rows.Err() } +// ListPendingSupplementsByProject returns undigested pending rows for a project (optional doc filter). +func (e *GovernanceEngine) ListPendingSupplementsByProject(ctx context.Context, projectID int64, docID string) ([]PendingSupplement, error) { + if e == nil || e.pool == nil || projectID <= 0 { + return nil, nil + } + docID = strings.TrimSpace(docID) + var rows pgx.Rows + var err error + if docID != "" { + rows, err = e.pool.Query(ctx, ` + SELECT id, doc_id, project_id, source_summary, impact_reason, initiator_user_id, batch_id, + status, digested_at, decided_by, created_at + FROM document_pending_supplements + WHERE project_id = $1 AND doc_id = $2 AND status = $3 + ORDER BY created_at ASC, id ASC + `, projectID, docID, PendingSupplementPending) + } else { + rows, err = e.pool.Query(ctx, ` + SELECT id, doc_id, project_id, source_summary, impact_reason, initiator_user_id, batch_id, + status, digested_at, decided_by, created_at + FROM document_pending_supplements + WHERE project_id = $1 AND status = $2 + ORDER BY created_at ASC, id ASC + `, projectID, PendingSupplementPending) + } + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]PendingSupplement, 0) + for rows.Next() { + var p PendingSupplement + if err := rows.Scan( + &p.ID, &p.DocID, &p.ProjectID, &p.SourceSummary, &p.ImpactReason, &p.InitiatorUserID, &p.BatchID, + &p.Status, &p.DigestedAt, &p.DecidedBy, &p.CreatedAt, + ); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + // DigestPendingSupplements marks all pending rows digested when a document returns to drafting. // It returns the digested rows so callers can notify the author. func (e *GovernanceEngine) DigestPendingSupplements(ctx context.Context, docID string, decidedBy int64) ([]PendingSupplement, error) { diff --git a/internal/docgov/engine_proposal.go b/internal/docgov/engine_proposal.go index 801da377..b122bbab 100644 --- a/internal/docgov/engine_proposal.go +++ b/internal/docgov/engine_proposal.go @@ -263,6 +263,19 @@ func (e *GovernanceEngine) AddComment( CreatedAt: now, } + if e.pool == nil { + // in-memory: allocate synthetic ids so ResolveComment can address them + var maxID int64 + for _, list := range e.memComms { + for _, c := range list { + if c != nil && c.ID > maxID { + maxID = c.ID + } + } + } + comment.ID = maxID + 1 + } + e.memComms[docID] = append(e.memComms[docID], comment) if e.pool != nil { diff --git a/internal/intent/dialogue_act.go b/internal/intent/dialogue_act.go index e78b5fd3..e413283c 100644 --- a/internal/intent/dialogue_act.go +++ b/internal/intent/dialogue_act.go @@ -10,11 +10,11 @@ import ( type DialogueAct string const ( - DialogueActAsk DialogueAct = "ask" - DialogueActCommand DialogueAct = "command" - DialogueActConfirm DialogueAct = "confirm" - DialogueActDecline DialogueAct = "decline" - DialogueActOther DialogueAct = "other" + DialogueActAsk DialogueAct = "ask" + DialogueActCommand DialogueAct = "command" + DialogueActConfirm DialogueAct = "confirm" + DialogueActDecline DialogueAct = "decline" + DialogueActOther DialogueAct = "other" ) // detectDialogueAct 识别查询/命令/确认/拒绝语气(规则轻量版) @@ -85,6 +85,7 @@ func isWriteIntent(t IntentType) bool { case IntentAdvanceStage, IntentTriggerTask, IntentTriggerAdhocTask, IntentSupplementInfo, IntentDocGovClaim, IntentDocGovSubmitReview, IntentDocGovReviewApprove, IntentDocGovReviewReject, IntentDocGovFinalApprove, IntentDocGovFinalReject, IntentDocGovProposalDecide, + IntentDocGovCommentAdd, IntentDocGovCommentResolve, IntentDocGovReopenRequest, IntentDocGovReopenApprove, IntentConfirmAction, IntentExecuteStageAdvance, IntentSwitchWorkflow, IntentTriggerWorkflow, IntentRunResume, IntentRunRestart, @@ -104,7 +105,7 @@ func IsQueryLegacyIntent(t IntentType) bool { func isQueryIntent(t IntentType) bool { switch t { case IntentQueryProgress, IntentQueryRegulations, IntentQuerySimilarCases, - IntentQueryUserRights, IntentQueryRejectReason, + IntentQueryUserRights, IntentQueryRejectReason, IntentQueryPendingSupplements, IntentQueryWorkflowStatus, IntentQueryDocStatus, IntentHelpCapabilities, IntentRunStatusQuery, IntentImpactAnalyze: return true diff --git a/internal/intent/hard_negative_test.go b/internal/intent/hard_negative_test.go index b5ef18cb..a8a22f11 100644 --- a/internal/intent/hard_negative_test.go +++ b/internal/intent/hard_negative_test.go @@ -10,8 +10,8 @@ import ( "testing" "time" - "chat-agent-system/internal/maturity" "chat-agent-system/internal/intent" + "chat-agent-system/internal/maturity" ) type goldenFile struct { @@ -148,12 +148,16 @@ func assertGoldenExpect(t *testing.T, caseID string, exp *goldenExpect, res *int } if exp.MustNotWrite { writeIntents := map[intent.IntentType]bool{ - intent.IntentDocGovClaim: true, - intent.IntentDocGovSubmitReview: true, - intent.IntentDocGovReviewApprove: true, - intent.IntentDocGovReviewReject: true, - intent.IntentDocGovFinalApprove: true, - intent.IntentDocGovFinalReject: true, + intent.IntentDocGovClaim: true, + intent.IntentDocGovSubmitReview: true, + intent.IntentDocGovReviewApprove: true, + intent.IntentDocGovReviewReject: true, + intent.IntentDocGovFinalApprove: true, + intent.IntentDocGovFinalReject: true, + intent.IntentDocGovCommentAdd: true, + intent.IntentDocGovCommentResolve: true, + intent.IntentDocGovReopenRequest: true, + intent.IntentDocGovReopenApprove: true, } if writeIntents[res.TopIntent.Intent] { t.Errorf("[%s] must not route to write intent %s", caseID, res.TopIntent.Intent) diff --git a/internal/intent/recognizer_matchers_docgov.go b/internal/intent/recognizer_matchers_docgov.go index ef442fbe..513f499c 100644 --- a/internal/intent/recognizer_matchers_docgov.go +++ b/internal/intent/recognizer_matchers_docgov.go @@ -2,6 +2,7 @@ package intent import ( "fmt" + "regexp" "strings" ) @@ -101,9 +102,10 @@ func matchDocGovAction(input string, userCtx *ContextSnapshot) *CandidateIntent } } - // 2. 终审批准 (Final Approve) + // 2. 终审批准 (Final Approve) — 排除重开语义,避免「批准重开」误入终审 if strings.Contains(input, "终审批准") || strings.Contains(input, "批准发布") || strings.Contains(input, "批准生效") || - strings.Contains(input, "同意发布") || (strings.Contains(input, "批准") && !strings.Contains(input, "不")) { + strings.Contains(input, "同意发布") || (strings.Contains(input, "批准") && !strings.Contains(input, "不") && + !strings.Contains(input, "重开") && !strings.Contains(input, "再打开") && !strings.Contains(input, "再修订")) { targetIntent := IntentDocGovFinalApprove slots := make(map[string]string) targetDoc := resolveTargetDoc(input, userCtx) @@ -256,5 +258,306 @@ func matchDocGovAction(input string, userCtx *ContextSnapshot) *CandidateIntent } } + // 7. 积压补充查询(只读)— 置于写动作前,避免「积压是什么」被误触 + if isPendingSupplementsQuery(input) { + slots := make(map[string]string) + fillTargetDocSlot(slots, input, userCtx) + return &CandidateIntent{ + Intent: IntentQueryPendingSupplements, + Confidence: 0.95, + Branch: BranchNewIntentPreempt, + FilledSlots: slots, + Reasoning: "用户查询锁定期间积压/待消化补充,路由为只读 QUERY_PENDING_SUPPLEMENTS", + } + } + + // 8. 重开批准(须显式「批准/同意」+「重开/再打开/再修订」;裸词「重开」不触发) + if isReopenApprovePhrase(input) { + slots := make(map[string]string) + fillTargetDocSlot(slots, input, userCtx) + confidence := 0.95 + reasoning := "用户下达批准重开/再修订指令" + clarifyQ := "" + if userCtx != nil { + if userCtx.UserDocRole != "" && userCtx.UserDocRole != "approver" && userCtx.UserDocRole != "system" { + confidence = 0.90 + reasoning = fmt.Sprintf("前置剪枝:当前用户角色不是批准人(UserDocRole: %s),无法批准重开", userCtx.UserDocRole) + clarifyQ = fmt.Sprintf("您当前在受控文档中的角色为【%s】,批准重开需批准人(或创建人兜底,由服务端二次校验)。", userCtx.UserDocRole) + } + if userCtx.DocLifecycleState != "" && userCtx.DocLifecycleState != "approved" { + confidence = 0.90 + reasoning = fmt.Sprintf("前置剪枝:当前文档处于【%s】阶段,重开仅适用于已批准(approved)文档", userCtx.DocLifecycleState) + clarifyQ = fmt.Sprintf("当前文档处于【%s】阶段,仅已批准文档可申请/批准重开。", userCtx.DocLifecycleState) + } + } + return &CandidateIntent{ + Intent: IntentDocGovReopenApprove, + Confidence: confidence, + Branch: BranchNewIntentPreempt, + FilledSlots: slots, + Reasoning: reasoning, + ClarificationQuestion: clarifyQ, + } + } + + // 9. 重开申请(须「申请」+「重开/再修订/再打开」,或显式文档引用 + 申请语义;裸词「重开」不触发) + if isReopenRequestPhrase(input) { + if detectDialogueAct(input) == DialogueActAsk { + slots := make(map[string]string) + fillTargetDocSlot(slots, input, userCtx) + return &CandidateIntent{ + Intent: IntentQueryDocStatus, + Confidence: 0.91, + Branch: BranchNewIntentPreempt, + FilledSlots: slots, + Reasoning: "查询语气下的重开状态询问,不得路由为写操作 DOCGOV_REOPEN_REQUEST", + ClarificationQuestion: "您是想查询该文档是否已批准、可否申请重开吗?我将检索状态,不会发起重开申请。", + } + } + slots := make(map[string]string) + fillTargetDocSlot(slots, input, userCtx) + if reason := extractReopenReason(input); reason != "" { + slots[SlotReopenReason] = reason + } + confidence := 0.94 + reasoning := "用户下达申请再修订/重开指令" + clarifyQ := "" + if slots[SlotReopenReason] == "" { + confidence = 0.90 + clarifyQ = "请补充重开原因(例如:申请重开《需求表》,原因:需补充风险分析章节)。" + reasoning = "重开申请缺少原因槽位,需澄清" + } + if userCtx != nil { + role := userCtx.UserDocRole + if role != "" && role != "author" && role != "creator" && role != "system" { + confidence = 0.90 + reasoning = fmt.Sprintf("前置剪枝:当前用户角色为【%s】,申请重开通常需作者/职责人(服务端将二次校验)", role) + clarifyQ = fmt.Sprintf("您当前角色为【%s】。申请重开需为文档职责人或项目创建人;确认后由服务端校验。", role) + } + if userCtx.DocLifecycleState != "" && userCtx.DocLifecycleState != "approved" { + confidence = 0.90 + reasoning = fmt.Sprintf("前置剪枝:当前文档处于【%s】阶段,仅已批准(approved)可申请重开", userCtx.DocLifecycleState) + clarifyQ = fmt.Sprintf("当前文档处于【%s】阶段,仅已批准文档可申请重开。", userCtx.DocLifecycleState) + } + } + return &CandidateIntent{ + Intent: IntentDocGovReopenRequest, + Confidence: confidence, + Branch: BranchNewIntentPreempt, + FilledSlots: slots, + Reasoning: reasoning, + ClarificationQuestion: clarifyQ, + } + } + + // 10. 批注解决/关闭 + if isCommentResolvePhrase(input) { + slots := make(map[string]string) + fillTargetDocSlot(slots, input, userCtx) + if sec := extractSection(input); sec != "" { + slots[SlotTargetSection] = sec + } + return &CandidateIntent{ + Intent: IntentDocGovCommentResolve, + Confidence: 0.93, + Branch: BranchNewIntentPreempt, + FilledSlots: slots, + Reasoning: "用户下达解决/关闭批注意图", + } + } + + // 11. 添加批注(须批注词 + 治理动词共现;查询语气走只读;缺内容澄清) + if isCommentAddPhrase(input) { + slots := make(map[string]string) + fillTargetDocSlot(slots, input, userCtx) + if sec := extractSection(input); sec != "" { + slots[SlotTargetSection] = sec + } + if ct := extractCommentType(input); ct != "" { + slots[SlotCommentType] = ct + } else { + slots[SlotCommentType] = "suggestion" + } + if content := extractCommentContent(input); content != "" { + slots[SlotCommentContent] = content + } + + if detectDialogueAct(input) == DialogueActAsk || isCommentStatusQuery(input) { + return &CandidateIntent{ + Intent: IntentQueryDocStatus, + Confidence: 0.91, + Branch: BranchNewIntentPreempt, + FilledSlots: slots, + Reasoning: "查询语气下的批注询问,不得路由为写操作 DOCGOV_COMMENT_ADD", + ClarificationQuestion: "您是想查询该文档已有批注吗?我将检索批注列表,不会新增批注。", + } + } + + confidence := 0.94 + reasoning := "用户下达添加批注/意见指令" + clarifyQ := "" + if slots[SlotCommentContent] == "" { + confidence = 0.90 + clarifyQ = "请说明要添加的批注内容(例如:给第3节加条意见:风险分析缺少残留风险评价)。" + reasoning = "批注意图缺少内容槽位,需澄清" + } + if userCtx != nil { + if !commentRoleStateAllowed(userCtx.UserDocRole, userCtx.DocLifecycleState) { + confidence = 0.90 + reasoning = fmt.Sprintf("前置剪枝:角色【%s】在阶段【%s】不允许批注(按批注矩阵)", userCtx.UserDocRole, userCtx.DocLifecycleState) + clarifyQ = fmt.Sprintf("按批注矩阵,角色【%s】在【%s】阶段不可添加批注。", userCtx.UserDocRole, userCtx.DocLifecycleState) + } + } + return &CandidateIntent{ + Intent: IntentDocGovCommentAdd, + Confidence: confidence, + Branch: BranchNewIntentPreempt, + FilledSlots: slots, + Reasoning: reasoning, + ClarificationQuestion: clarifyQ, + } + } + return nil } + +func isPendingSupplementsQuery(input string) bool { + markers := []string{ + "待消化的补充", "待消化补充", "积压补充", "锁定期间补充", "锁定期补充", + "积压的补充", "未消化补充", "pending supplement", + } + for _, m := range markers { + if strings.Contains(input, m) { + return true + } + } + if strings.Contains(input, "积压") && (strings.Contains(input, "补充") || strings.Contains(input, "是什么") || strings.Contains(input, "有哪些") || strings.Contains(input, "查询")) { + return true + } + return false +} + +func isReopenApprovePhrase(input string) bool { + hasApprove := strings.Contains(input, "批准重开") || strings.Contains(input, "同意重开") || + strings.Contains(input, "批准再打开") || strings.Contains(input, "同意再打开") || + strings.Contains(input, "批准再修订") || strings.Contains(input, "同意再修订") + if hasApprove { + return true + } + hasReopen := strings.Contains(input, "重开") || strings.Contains(input, "再打开") || strings.Contains(input, "再修订") + hasApproveVerb := strings.Contains(input, "批准") || strings.Contains(input, "同意") + return hasReopen && hasApproveVerb && !strings.Contains(input, "申请") +} + +func isReopenRequestPhrase(input string) bool { + // 裸词「重开」不触发 + if strings.TrimSpace(input) == "重开" { + return false + } + explicit := []string{"申请重开", "申请再修订", "申请再打开", "申请重新打开", "请求重开", "请求再修订"} + for _, p := range explicit { + if strings.Contains(input, p) { + return true + } + } + hasReopen := strings.Contains(input, "重开") || strings.Contains(input, "再打开") || strings.Contains(input, "再修订") + hasApply := strings.Contains(input, "申请") || strings.Contains(input, "请求") + if hasReopen && hasApply { + return true + } + // 显式文档引用 + 再打开/再修订(仍须避免裸词) + if hasReopen && extractDocName(input) != "" && (strings.Contains(input, "再") || strings.Contains(input, "申请")) { + return true + } + return false +} + +func isCommentResolvePhrase(input string) bool { + markers := []string{"解决批注", "关闭批注", "批注已解决", "批注已处理", "标记批注已解决", "resolve批注"} + for _, m := range markers { + if strings.Contains(input, m) { + return true + } + } + return (strings.Contains(input, "批注") || strings.Contains(input, "意见")) && + (strings.Contains(input, "解决") || strings.Contains(input, "关闭")) && + !strings.Contains(input, "添加") && !strings.Contains(input, "加条") +} + +func isCommentAddPhrase(input string) bool { + // 批注词必须与治理动词共现,避免「批注」裸词误触 + govVerbs := []string{"添加批注", "加条意见", "加条批注", "加上批注", "写条批注", "写个批注", "新增批注", "添加意见", "给.*加.*意见", "批注:", "批注:"} + for _, v := range govVerbs { + if strings.Contains(v, ".*") { + continue + } + if strings.Contains(input, v) { + return true + } + } + hasCommentWord := strings.Contains(input, "批注") || strings.Contains(input, "加条意见") || strings.Contains(input, "添加意见") + hasAction := strings.Contains(input, "添加") || strings.Contains(input, "加条") || strings.Contains(input, "加上") || + strings.Contains(input, "写") || strings.Contains(input, "新增") || strings.Contains(input, "给") + return hasCommentWord && hasAction +} + +func isCommentStatusQuery(input string) bool { + markers := []string{"有哪些批注", "有什么批注", "批注列表", "查看批注", "查一下批注", "我的批注"} + for _, m := range markers { + if strings.Contains(input, m) { + return true + } + } + return false +} + +func extractCommentContent(input string) string { + if r := extractRejectReason(input); r != "" { + return r + } + re := regexp.MustCompile(`(?:添加批注|加条意见|加条批注|加上批注|写条批注|写个批注|新增批注|添加意见)[::\s]*(.+)`) + if match := re.FindStringSubmatch(input); len(match) > 1 { + return strings.Trim(strings.TrimSpace(match[1]), ",,。;; ") + } + return "" +} + +func extractCommentType(input string) string { + switch { + case strings.Contains(input, "问题") || strings.Contains(input, "issue"): + return "issue" + case strings.Contains(input, "同意") || strings.Contains(input, "approval") || strings.Contains(input, "认可"): + return "approval" + case strings.Contains(input, "建议") || strings.Contains(input, "suggestion"): + return "suggestion" + default: + return "" + } +} + +func extractReopenReason(input string) string { + re := regexp.MustCompile(`(?:原因|理由|因为|由于)[::\s]*(.+)`) + if match := re.FindStringSubmatch(input); len(match) > 1 { + return strings.Trim(strings.TrimSpace(match[1]), ",,。;; ") + } + return "" +} + +// commentRoleStateAllowed mirrors docgov.roleAllowedToComment for Stage-0 pruning. +func commentRoleStateAllowed(role, state string) bool { + if role == "" || state == "" { + return true // 快照缺失时不剪枝,交给服务端二次校验 + } + switch state { + case "drafting": + return role == "author" || role == "reviewer" || role == "system" + case "reviewing": + return role == "reviewer" || role == "author" || role == "system" + case "approving": + return role == "approver" || role == "reviewer" || role == "system" + case "approved": + return false + default: + return false + } +} diff --git a/internal/intent/recognizer_test.go b/internal/intent/recognizer_test.go index 0bc5f4fd..78f58015 100644 --- a/internal/intent/recognizer_test.go +++ b/internal/intent/recognizer_test.go @@ -602,7 +602,6 @@ func TestIntentRecognition_FinalReject_MissingReason(t *testing.T) { } } - func TestIntentRecognition_SecurityFalsePositives_NotBlocked(t *testing.T) { rec := intent.NewRecognizer() testInputs := []struct { @@ -905,3 +904,105 @@ func TestIntentRecognition_ConsultativeStillAmbiguous(t *testing.T) { t.Error("short fragment must not count as general question") } } + +func TestIntentRecognition_DocGov_CommentReopenPending(t *testing.T) { + rec := intent.NewRecognizer() + ctxAuthor := &intent.ContextSnapshot{ + ActiveDocID: "doc-001", + DocLifecycleState: "drafting", + UserDocRole: "author", + } + + resAdd, err := rec.Recognize(context.Background(), "给第3节添加批注:风险分析缺少残留风险评价", ctxAuthor) + if err != nil { + t.Fatalf("comment add: %v", err) + } + if resAdd.TopIntent.Intent != intent.IntentDocGovCommentAdd { + t.Errorf("expected DOCGOV_COMMENT_ADD, got %s", resAdd.TopIntent.Intent) + } + if resAdd.TopIntent.FilledSlots[intent.SlotCommentContent] == "" { + t.Error("expected comment_content slot") + } + + resAsk, err := rec.Recognize(context.Background(), "我有哪些批注?", ctxAuthor) + if err != nil { + t.Fatalf("comment ask: %v", err) + } + if resAsk.TopIntent.Intent == intent.IntentDocGovCommentAdd { + t.Errorf("query tone must not route to COMMENT_ADD, got %s", resAsk.TopIntent.Intent) + } + + resBare, err := rec.Recognize(context.Background(), "重开", ctxAuthor) + if err != nil { + t.Fatalf("bare reopen: %v", err) + } + if resBare.TopIntent.Intent == intent.IntentDocGovReopenRequest || resBare.TopIntent.Intent == intent.IntentDocGovReopenApprove { + t.Errorf("bare 重开 must not trigger reopen write, got %s", resBare.TopIntent.Intent) + } + + ctxApproved := &intent.ContextSnapshot{ + ActiveDocID: "doc-001", + DocLifecycleState: "approved", + UserDocRole: "author", + } + resReopen, err := rec.Recognize(context.Background(), "申请重开需求收集表,原因:需补充风险分析", ctxApproved) + if err != nil { + t.Fatalf("reopen request: %v", err) + } + if resReopen.TopIntent.Intent != intent.IntentDocGovReopenRequest { + t.Errorf("expected DOCGOV_REOPEN_REQUEST, got %s", resReopen.TopIntent.Intent) + } + if resReopen.TopIntent.FilledSlots[intent.SlotReopenReason] == "" { + t.Error("expected reopen_reason slot") + } + + ctxApprover := &intent.ContextSnapshot{ + ActiveDocID: "doc-001", + DocLifecycleState: "approved", + UserDocRole: "approver", + } + resApprove, err := rec.Recognize(context.Background(), "批准重开这份需求收集表", ctxApprover) + if err != nil { + t.Fatalf("reopen approve: %v", err) + } + if resApprove.TopIntent.Intent != intent.IntentDocGovReopenApprove { + t.Errorf("expected DOCGOV_REOPEN_APPROVE, got %s", resApprove.TopIntent.Intent) + } + + resPending, err := rec.Recognize(context.Background(), "当前有哪些积压补充?", ctxApproved) + if err != nil { + t.Fatalf("pending: %v", err) + } + if resPending.TopIntent.Intent != intent.IntentQueryPendingSupplements { + t.Errorf("expected QUERY_PENDING_SUPPLEMENTS, got %s", resPending.TopIntent.Intent) + } + + // 批注矩阵:approved 阶段剪枝 + ctxLocked := &intent.ContextSnapshot{ + ActiveDocID: "doc-001", + DocLifecycleState: "approved", + UserDocRole: "author", + } + resMatrix, err := rec.Recognize(context.Background(), "添加批注:不应在已批准阶段出现", ctxLocked) + if err != nil { + t.Fatalf("matrix: %v", err) + } + if resMatrix.TopIntent.Intent != intent.IntentDocGovCommentAdd { + t.Errorf("expected COMMENT_ADD with clarify, got %s", resMatrix.TopIntent.Intent) + } + if resMatrix.TopIntent.ClarificationQuestion == "" { + t.Error("expected clarification for approved-stage comment prune") + } + + // 缺内容澄清 + resMissing, err := rec.Recognize(context.Background(), "添加批注", ctxAuthor) + if err != nil { + t.Fatalf("missing content: %v", err) + } + if resMissing.TopIntent.Intent != intent.IntentDocGovCommentAdd { + t.Errorf("expected COMMENT_ADD, got %s", resMissing.TopIntent.Intent) + } + if resMissing.TopIntent.ClarificationQuestion == "" { + t.Error("expected clarification when comment content missing") + } +} diff --git a/internal/intent/registry.go b/internal/intent/registry.go index 91c50810..b1436c1e 100644 --- a/internal/intent/registry.go +++ b/internal/intent/registry.go @@ -16,7 +16,7 @@ const ( // ActionAlias 自然语言别名 type ActionAlias struct { - Phrase string `json:"phrase"` + Phrase string `json:"phrase"` Acts []DialogueAct `json:"acts,omitempty"` } @@ -29,30 +29,30 @@ type SlotSpec struct { // ActionSpec 可识别、可授权、可执行的动作单一事实源 type ActionSpec struct { - Key string `json:"key"` - Domain string `json:"domain"` - Object string `json:"object"` - Goal GoalType `json:"goal"` - Risk RiskLevel `json:"risk"` + Key string `json:"key"` + Domain string `json:"domain"` + Object string `json:"object"` + Goal GoalType `json:"goal"` + Risk RiskLevel `json:"risk"` - Aliases []ActionAlias `json:"aliases,omitempty"` - DialogueActs []DialogueAct `json:"dialogue_acts,omitempty"` + Aliases []ActionAlias `json:"aliases,omitempty"` + DialogueActs []DialogueAct `json:"dialogue_acts,omitempty"` - RequiredSlots []SlotSpec `json:"required_slots,omitempty"` - OptionalSlots []SlotSpec `json:"optional_slots,omitempty"` + RequiredSlots []SlotSpec `json:"required_slots,omitempty"` + OptionalSlots []SlotSpec `json:"optional_slots,omitempty"` - CapabilityKey string `json:"capability_key,omitempty"` - AllowedStates []string `json:"allowed_states,omitempty"` - RequiresConfirm bool `json:"requires_confirm,omitempty"` - IdempotencyMode string `json:"idempotency_mode,omitempty"` + CapabilityKey string `json:"capability_key,omitempty"` + AllowedStates []string `json:"allowed_states,omitempty"` + RequiresConfirm bool `json:"requires_confirm,omitempty"` + IdempotencyMode string `json:"idempotency_mode,omitempty"` - LegacyIntent IntentType `json:"legacy_intent"` + LegacyIntent IntentType `json:"legacy_intent"` } // ActionRegistry 动作注册表 type ActionRegistry struct { - mu sync.RWMutex - byKey map[string]*ActionSpec + mu sync.RWMutex + byKey map[string]*ActionSpec byLegacy map[IntentType]*ActionSpec } @@ -158,6 +158,11 @@ func registerBuiltinActions(r *ActionRegistry) { {Key: "document.final.approve", Domain: "document_gov", Object: "controlled_document", Goal: GoalMutate, Risk: RiskHigh, LegacyIntent: IntentDocGovFinalApprove, RequiresConfirm: true, DialogueActs: []DialogueAct{DialogueActCommand, DialogueActConfirm}}, {Key: "document.final.reject", Domain: "document_gov", Object: "controlled_document", Goal: GoalMutate, Risk: RiskHigh, LegacyIntent: IntentDocGovFinalReject, RequiresConfirm: true, RequiredSlots: []SlotSpec{{Key: SlotRejectReason, Required: true, Label: "驳回理由"}}, DialogueActs: []DialogueAct{DialogueActCommand}}, {Key: "document.proposal.decide", Domain: "document_gov", Object: "controlled_document", Goal: GoalDecide, Risk: RiskMedium, LegacyIntent: IntentDocGovProposalDecide, DialogueActs: []DialogueAct{DialogueActConfirm, DialogueActDecline}}, + {Key: "document.comment.add", Domain: "document_gov", Object: "controlled_document", Goal: GoalMutate, Risk: RiskMedium, LegacyIntent: IntentDocGovCommentAdd, CapabilityKey: "DOCGOV_COMMENT_ADD", RequiresConfirm: true, RequiredSlots: []SlotSpec{{Key: SlotCommentContent, Required: true, Label: "批注内容"}}, DialogueActs: []DialogueAct{DialogueActCommand}}, + {Key: "document.comment.resolve", Domain: "document_gov", Object: "controlled_document", Goal: GoalMutate, Risk: RiskMedium, LegacyIntent: IntentDocGovCommentResolve, CapabilityKey: "DOCGOV_COMMENT_RESOLVE", RequiresConfirm: true, DialogueActs: []DialogueAct{DialogueActCommand}}, + {Key: "document.reopen.request", Domain: "document_gov", Object: "controlled_document", Goal: GoalMutate, Risk: RiskHigh, LegacyIntent: IntentDocGovReopenRequest, CapabilityKey: "DOCGOV_REOPEN_REQUEST", RequiresConfirm: true, RequiredSlots: []SlotSpec{{Key: SlotReopenReason, Required: true, Label: "重开原因"}}, DialogueActs: []DialogueAct{DialogueActCommand}}, + {Key: "document.reopen.approve", Domain: "document_gov", Object: "controlled_document", Goal: GoalMutate, Risk: RiskHigh, LegacyIntent: IntentDocGovReopenApprove, CapabilityKey: "DOCGOV_REOPEN_APPROVE", RequiresConfirm: true, DialogueActs: []DialogueAct{DialogueActCommand, DialogueActConfirm}}, + {Key: "document.pending_supplements.query", Domain: "document_gov", Object: "controlled_document", Goal: GoalQuery, Risk: RiskLow, LegacyIntent: IntentQueryPendingSupplements, CapabilityKey: "QUERY_PENDING_SUPPLEMENTS", DialogueActs: []DialogueAct{DialogueActAsk, DialogueActCommand}}, {Key: "regulation.query", Domain: "knowledge", Object: "regulation", Goal: GoalQuery, Risk: RiskLow, LegacyIntent: IntentQueryRegulations, CapabilityKey: "QUERY_REGULATIONS", DialogueActs: []DialogueAct{DialogueActAsk}}, {Key: "case.query", Domain: "knowledge", Object: "artifact", Goal: GoalQuery, Risk: RiskLow, LegacyIntent: IntentQuerySimilarCases, CapabilityKey: "QUERY_SIMILAR_CASES", DialogueActs: []DialogueAct{DialogueActAsk}}, diff --git a/internal/intent/registry_test.go b/internal/intent/registry_test.go index 48a7631a..5b0db775 100644 --- a/internal/intent/registry_test.go +++ b/internal/intent/registry_test.go @@ -10,7 +10,9 @@ func TestDefaultRegistry_CoversAllLegacyIntents(t *testing.T) { IntentDocGovClaim, IntentDocGovSubmitReview, IntentDocGovReviewApprove, IntentDocGovReviewReject, IntentDocGovFinalApprove, IntentDocGovFinalReject, IntentDocGovProposalDecide, + IntentDocGovCommentAdd, IntentDocGovCommentResolve, IntentDocGovReopenRequest, IntentDocGovReopenApprove, IntentQueryRegulations, IntentQuerySimilarCases, IntentQueryUserRights, IntentQueryRejectReason, + IntentQueryPendingSupplements, IntentConfirmAction, IntentExecuteStageAdvance, IntentSecurityForbidden, IntentSwitchWorkflow, IntentTriggerWorkflow, IntentQueryWorkflowStatus, IntentQueryDocStatus, IntentHelpCapabilities, IntentUnsupportedAction, diff --git a/internal/intent/types.go b/internal/intent/types.go index b493c869..2d6425ec 100644 --- a/internal/intent/types.go +++ b/internal/intent/types.go @@ -24,12 +24,17 @@ const ( IntentDocGovFinalApprove IntentType = "DOCGOV_FINAL_APPROVE" IntentDocGovFinalReject IntentType = "DOCGOV_FINAL_REJECT" IntentDocGovProposalDecide IntentType = "DOCGOV_PROPOSAL_DECIDE" + IntentDocGovCommentAdd IntentType = "DOCGOV_COMMENT_ADD" + IntentDocGovCommentResolve IntentType = "DOCGOV_COMMENT_RESOLVE" + IntentDocGovReopenRequest IntentType = "DOCGOV_REOPEN_REQUEST" + IntentDocGovReopenApprove IntentType = "DOCGOV_REOPEN_APPROVE" // 决策与合规查询 - IntentQueryRegulations IntentType = "QUERY_REGULATIONS" - IntentQuerySimilarCases IntentType = "QUERY_SIMILAR_CASES" - IntentQueryUserRights IntentType = "QUERY_USER_RIGHTS" - IntentQueryRejectReason IntentType = "QUERY_REJECT_REASON" // 查询受控文档驳回/退回原因 + IntentQueryRegulations IntentType = "QUERY_REGULATIONS" + IntentQuerySimilarCases IntentType = "QUERY_SIMILAR_CASES" + IntentQueryUserRights IntentType = "QUERY_USER_RIGHTS" + IntentQueryRejectReason IntentType = "QUERY_REJECT_REASON" // 查询受控文档驳回/退回原因 + IntentQueryPendingSupplements IntentType = "QUERY_PENDING_SUPPLEMENTS" // 协同拍板与阶段执行动作 IntentConfirmAction IntentType = "CONFIRM_ACTION" // 用户拍板确认交互卡片 @@ -145,13 +150,16 @@ const ( SlotQueryTopic = "query_topic" // 查询主题 (如 progress, risk, talk_script, quotation) // 法定文档治理与权限槽位 - SlotTargetDoc = "target_doc" + SlotTargetDoc = "target_doc" // TargetDocLatestUpload 是「刚刚/最近上传」指代的哨兵目标,由服务端解析为项目最新 CAS 资产。 TargetDocLatestUpload = "@latest_upload" - SlotRejectReason = "reject_reason" - SlotTargetSection = "target_section" - SlotProposalAction = "proposal_action" - SlotForbiddenTarget = "forbidden_target" + SlotRejectReason = "reject_reason" + SlotTargetSection = "target_section" + SlotProposalAction = "proposal_action" + SlotForbiddenTarget = "forbidden_target" + SlotCommentContent = "comment_content" + SlotCommentType = "comment_type" // suggestion | issue | approval + SlotReopenReason = "reopen_reason" // 路由层槽位 SlotTaskBrief = "task_brief" // 自由任务简报(升级建议携带的用户原始诉求) diff --git a/internal/notification/digest.go b/internal/notification/digest.go index 44cdbb2e..ac7ec8ea 100644 --- a/internal/notification/digest.go +++ b/internal/notification/digest.go @@ -8,41 +8,41 @@ import ( "time" ) - const ( - DigestKindProposalRequired = "proposal_required" - DigestKindUnclaimedEdit = "unclaimed_edit" - DigestKindPendingSupplements = "pending_supplements" - DigestKindReviewRequested = "review_requested" - DigestKindApprovalRequested = "approval_requested" - SeverityActionRequired = "action_required" - SeverityInfo = "info" - maxDigestItems = 20 - ) +const ( + DigestKindProposalRequired = "proposal_required" + DigestKindUnclaimedEdit = "unclaimed_edit" + DigestKindPendingSupplements = "pending_supplements" + DigestKindReviewRequested = "review_requested" + DigestKindApprovalRequested = "approval_requested" + SeverityActionRequired = "action_required" + SeverityInfo = "info" + maxDigestItems = 20 +) // DigestItem is one occurrence folded into a daily digest. type DigestItem struct { - At time.Time `json:"at"` - ActorID int64 `json:"actor_id"` - ActorName string `json:"actor_name"` - Summary string `json:"summary"` - ProposalID string `json:"proposal_id,omitempty"` - ChangeCount int `json:"change_count,omitempty"` + At time.Time `json:"at"` + ActorID int64 `json:"actor_id"` + ActorName string `json:"actor_name"` + Summary string `json:"summary"` + ProposalID string `json:"proposal_id,omitempty"` + ChangeCount int `json:"change_count,omitempty"` } // UpsertDigestParams drives ON CONFLICT DO UPDATE aggregation. type UpsertDigestParams struct { - UserID int64 - ProjectID int64 - DigestKind string - DocID string - DocTitle string - Severity string - ActorID int64 - ActorName string - Summary string - ProposalID string - ChangeCount int - WindowDate time.Time // truncated to date; zero → today UTC + UserID int64 + ProjectID int64 + DigestKind string + DocID string + DocTitle string + Severity string + ActorID int64 + ActorName string + Summary string + ProposalID string + ChangeCount int + WindowDate time.Time // truncated to date; zero → today UTC } // UpsertDocumentDigest aggregates same-day notifications per (user, kind, doc). diff --git a/internal/notification/document_events.go b/internal/notification/document_events.go index 8f56a339..2920d192 100644 --- a/internal/notification/document_events.go +++ b/internal/notification/document_events.go @@ -14,27 +14,27 @@ const ( EventDocHumanRevisionConflict = "document.human_revision_conflict" EventDocDownstreamImpact = "document.downstream_impact_detected" EventDocChangeReportDegraded = "document.change_report_degraded" - EventDocProposalRequired = "document.proposal_required" - EventDocUnclaimedEditApplied = "document.unclaimed_edit_applied" - EventDocReviewRequested = "document.review_requested" - EventDocApprovalRequested = "document.approval_requested" - EventDocRejected = "document.rejected" - EventDocReopened = "document.reopened" - EventDocPendingSupplements = "document.pending_supplements" - EventDocApproved = "document.approved" + EventDocProposalRequired = "document.proposal_required" + EventDocUnclaimedEditApplied = "document.unclaimed_edit_applied" + EventDocReviewRequested = "document.review_requested" + EventDocApprovalRequested = "document.approval_requested" + EventDocRejected = "document.rejected" + EventDocReopened = "document.reopened" + EventDocPendingSupplements = "document.pending_supplements" + EventDocApproved = "document.approved" TypeDocAgentCandidateReady = "document_agent_candidate_ready" TypeDocAgentVersionCommitted = "document_agent_version_committed" TypeDocDownstreamImpact = "document_downstream_impact" TypeDocChangeReportDegraded = "document_change_report_degraded" TypeDocApproved = "document_approved" - TypeDocProposalRequired = "document_proposal_required" - TypeDocUnclaimedEditApplied = "document_unclaimed_edit_applied" - TypeDocReviewRequested = "document_review_requested" - TypeDocApprovalRequested = "document_approval_requested" - TypeDocRejected = "document_rejected" - TypeDocReopened = "document_reopened" - TypeDocPendingSupplements = "document_pending_supplements" + TypeDocProposalRequired = "document_proposal_required" + TypeDocUnclaimedEditApplied = "document_unclaimed_edit_applied" + TypeDocReviewRequested = "document_review_requested" + TypeDocApprovalRequested = "document_approval_requested" + TypeDocRejected = "document_rejected" + TypeDocReopened = "document_reopened" + TypeDocPendingSupplements = "document_pending_supplements" ) // DocumentEventPayload is a minimal, permission-safe notification body (no markdown excerpts). diff --git a/internal/server/server.go b/internal/server/server.go index 492442b8..517fccf4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -619,6 +619,9 @@ func (s *Server) getOrCreateSession( Workflow: adaptChatWorkflow(s.pwSvc()), Environment: env, } + if s.rt != nil { + sess.Actions.Reopens = s.rt.ChatReopens + } if s.vaultIngest != nil { ingest := s.vaultIngest sess.OnFinalArtifact = func(ctx context.Context, projectID int64, logical, eventID string, msgID int64, policy string, payload []byte) { diff --git a/internal/supplementimpact/http_adapter.go b/internal/supplementimpact/http_adapter.go index 56e1d96d..1cbe2234 100644 --- a/internal/supplementimpact/http_adapter.go +++ b/internal/supplementimpact/http_adapter.go @@ -8,19 +8,19 @@ import ( // HTTPBatchView is the JSON shape expected by server supplement-impact handlers. type HTTPBatchView struct { - BatchID string `json:"batch_id"` - ProjectID int64 `json:"project_id"` - SessionID string `json:"session_id,omitempty"` - InitiatorUserID int64 `json:"initiator_user_id"` - SourceKind string `json:"source_kind,omitempty"` - SourceSummary string `json:"source_summary,omitempty"` - CoverageStatus string `json:"coverage_status,omitempty"` - Status string `json:"status"` - DecisionNonce string `json:"decision_nonce"` - DecisionDigest string `json:"decision_digest,omitempty"` - Revision int64 `json:"revision"` - ExpiresAt time.Time `json:"expires_at"` - Items []HTTPItemView `json:"items,omitempty"` + BatchID string `json:"batch_id"` + ProjectID int64 `json:"project_id"` + SessionID string `json:"session_id,omitempty"` + InitiatorUserID int64 `json:"initiator_user_id"` + SourceKind string `json:"source_kind,omitempty"` + SourceSummary string `json:"source_summary,omitempty"` + CoverageStatus string `json:"coverage_status,omitempty"` + Status string `json:"status"` + DecisionNonce string `json:"decision_nonce"` + DecisionDigest string `json:"decision_digest,omitempty"` + Revision int64 `json:"revision"` + ExpiresAt time.Time `json:"expires_at"` + Items []HTTPItemView `json:"items,omitempty"` } type HTTPItemView struct { diff --git a/internal/supplementimpact/service.go b/internal/supplementimpact/service.go index 9ad4cc0b..bb6ac6cb 100644 --- a/internal/supplementimpact/service.go +++ b/internal/supplementimpact/service.go @@ -280,7 +280,7 @@ func (s *Service) executeItem(ctx context.Context, b *Batch, it *Item) error { UserID: it.AuthorIDSnapshot, ProjectID: b.ProjectID, DigestKind: notification.DigestKindProposalRequired, DocID: it.DocID, DocTitle: it.Title, Severity: notification.SeverityActionRequired, - ActorID: b.InitiatorUserID, ActorName: fmt.Sprintf("user:%d", b.InitiatorUserID), + ActorID: b.InitiatorUserID, ActorName: fmt.Sprintf("user:%d", b.InitiatorUserID), Summary: b.SourceSummary, ProposalID: prop.ProposalID, }) } @@ -293,7 +293,7 @@ func (s *Service) executeItem(ctx context.Context, b *Batch, it *Item) error { UserID: it.AuthorIDSnapshot, ProjectID: b.ProjectID, DigestKind: notification.DigestKindUnclaimedEdit, DocID: it.DocID, DocTitle: it.Title, Severity: notification.SeverityInfo, - ActorID: b.InitiatorUserID, ActorName: fmt.Sprintf("user:%d", b.InitiatorUserID), + ActorID: b.InitiatorUserID, ActorName: fmt.Sprintf("user:%d", b.InitiatorUserID), Summary: b.SourceSummary, }) } diff --git a/internal/supplementimpact/service_test.go b/internal/supplementimpact/service_test.go index c124b364..9d851881 100644 --- a/internal/supplementimpact/service_test.go +++ b/internal/supplementimpact/service_test.go @@ -17,7 +17,9 @@ func (s stubAnalyzer) Analyze(_ context.Context, _ int64, _ traceimpact.DocRef, return s.report, nil } -type stubDocs struct{ docs map[string]*docgov.DocumentRecord } +type stubDocs struct { + docs map[string]*docgov.DocumentRecord +} func (s stubDocs) GetDocument(_ context.Context, id string) (*docgov.DocumentRecord, error) { return s.docs[id], nil @@ -175,4 +177,3 @@ func TestDecideLockedSkipsAndRecordsPending(t *testing.T) { t.Fatalf("want pending_supplements digest, got %s", notify.params[0].DigestKind) } } - diff --git a/internal/supplementimpact/types.go b/internal/supplementimpact/types.go index be4b4b6f..8f83adb0 100644 --- a/internal/supplementimpact/types.go +++ b/internal/supplementimpact/types.go @@ -11,64 +11,64 @@ const ( StatusExpired = "expired" StatusCancelled = "cancelled" - OutcomeApplied = "applied" - OutcomeProposalPending = "proposal_pending" - OutcomeProposalRejected = "proposal_rejected" - OutcomeSkippedBusy = "skipped_busy" - OutcomeSkippedLocked = "skipped_locked" - OutcomeFailed = "failed" + OutcomeApplied = "applied" + OutcomeProposalPending = "proposal_pending" + OutcomeProposalRejected = "proposal_rejected" + OutcomeSkippedBusy = "skipped_busy" + OutcomeSkippedLocked = "skipped_locked" + OutcomeFailed = "failed" BatchTTL = 24 * time.Hour ) // Batch is one supplement → impact analysis confirmation unit. type Batch struct { - BatchID string `json:"batch_id"` - ProjectID int64 `json:"project_id"` - SessionID string `json:"session_id"` - InitiatorUserID int64 `json:"initiator_user_id"` - SourceKind string `json:"source_kind"` - SourceSummary string `json:"source_summary"` - CoverageStatus string `json:"coverage_status"` - Status string `json:"status"` - DecisionNonce string `json:"decision_nonce"` - DecisionDigest string `json:"decision_digest"` - Revision int64 `json:"revision"` - ExpiresAt time.Time `json:"expires_at"` - Items []Item `json:"items,omitempty"` + BatchID string `json:"batch_id"` + ProjectID int64 `json:"project_id"` + SessionID string `json:"session_id"` + InitiatorUserID int64 `json:"initiator_user_id"` + SourceKind string `json:"source_kind"` + SourceSummary string `json:"source_summary"` + CoverageStatus string `json:"coverage_status"` + Status string `json:"status"` + DecisionNonce string `json:"decision_nonce"` + DecisionDigest string `json:"decision_digest"` + Revision int64 `json:"revision"` + ExpiresAt time.Time `json:"expires_at"` + Items []Item `json:"items,omitempty"` } // Item is one affected document inside a batch. type Item struct { - ID int64 `json:"id"` - BatchID string `json:"batch_id"` - DocID string `json:"doc_id"` - Title string `json:"title"` - ItemKey string `json:"item_key"` - ImpactType string `json:"impact_type"` - Depth int `json:"depth"` - Reason string `json:"reason"` - AuthorClaimedSnapshot bool `json:"author_claimed_snapshot"` - AuthorIDSnapshot int64 `json:"author_id_snapshot"` - BusySnapshot bool `json:"busy_snapshot"` - LockedSnapshot bool `json:"locked_snapshot"` - ReviewerIDSnapshot int64 `json:"reviewer_id_snapshot,omitempty"` - ApproverIDSnapshot int64 `json:"approver_id_snapshot,omitempty"` - CurrentStateSnapshot string `json:"current_state_snapshot,omitempty"` - ChangeSummary string `json:"change_summary"` - Selected bool `json:"selected"` - Outcome string `json:"outcome"` - ProposalID string `json:"proposal_id"` - RunID string `json:"run_id"` + ID int64 `json:"id"` + BatchID string `json:"batch_id"` + DocID string `json:"doc_id"` + Title string `json:"title"` + ItemKey string `json:"item_key"` + ImpactType string `json:"impact_type"` + Depth int `json:"depth"` + Reason string `json:"reason"` + AuthorClaimedSnapshot bool `json:"author_claimed_snapshot"` + AuthorIDSnapshot int64 `json:"author_id_snapshot"` + BusySnapshot bool `json:"busy_snapshot"` + LockedSnapshot bool `json:"locked_snapshot"` + ReviewerIDSnapshot int64 `json:"reviewer_id_snapshot,omitempty"` + ApproverIDSnapshot int64 `json:"approver_id_snapshot,omitempty"` + CurrentStateSnapshot string `json:"current_state_snapshot,omitempty"` + ChangeSummary string `json:"change_summary"` + Selected bool `json:"selected"` + Outcome string `json:"outcome"` + ProposalID string `json:"proposal_id"` + RunID string `json:"run_id"` } // DecisionCommand is the confirm/cancel envelope (nonce+digest+revision CAS). type DecisionCommand struct { - BatchID string - ActorUserID int64 - Kind string // confirm | cancel - SelectedDocIDs []string - DecisionNonce string - DecisionDigest string - ExpectedRevision int64 + BatchID string + ActorUserID int64 + Kind string // confirm | cancel + SelectedDocIDs []string + DecisionNonce string + DecisionDigest string + ExpectedRevision int64 } diff --git a/internal/traceimpact/pg_store.go b/internal/traceimpact/pg_store.go index 654517d3..dd578e74 100644 --- a/internal/traceimpact/pg_store.go +++ b/internal/traceimpact/pg_store.go @@ -222,6 +222,28 @@ func (p *pgStore) GetReopen(ctx context.Context, id int64) (ReopenRequest, error return req, err } +func (p *pgStore) ListPendingReopens(ctx context.Context, projectID int64) ([]ReopenRequest, error) { + rows, err := p.pool.Query(ctx, ` + SELECT id, project_id, instance_id, stage_key, doc_task_keys, reason, requested_by, status + FROM stage_reopen_requests + WHERE project_id = $1 AND status = $2 + ORDER BY id ASC + `, projectID, ReopenPending) + if err != nil { + return nil, err + } + defer rows.Close() + var out []ReopenRequest + for rows.Next() { + var req ReopenRequest + if err := rows.Scan(&req.ID, &req.ProjectID, &req.InstanceID, &req.StageKey, &req.DocTasks, &req.Reason, &req.RequestedBy, &req.Status); err != nil { + return nil, err + } + out = append(out, req) + } + return out, rows.Err() +} + func (p *pgStore) PutMark(ctx context.Context, mark RecheckMark) (RecheckMark, error) { if mark.Status == "" { mark.Status = MarkOpen diff --git a/internal/traceimpact/service.go b/internal/traceimpact/service.go index c97f8ff3..eb84daf3 100644 --- a/internal/traceimpact/service.go +++ b/internal/traceimpact/service.go @@ -506,6 +506,17 @@ func (s *Service) GetReopen(ctx context.Context, id int64) (ReopenRequest, error return s.store.GetReopen(ctx, id) } +func (s *Service) ListPendingReopens(ctx context.Context, projectID int64) ([]ReopenRequest, error) { + if s == nil || s.store == nil { + return nil, nil + } + out, err := s.store.ListPendingReopens(ctx, projectID) + if err == nil && out == nil { + out = []ReopenRequest{} + } + return out, err +} + // ErrNeedsConfirm is returned when a revision proposal is requested before confirmation. var ErrNeedsConfirm = fmt.Errorf("traceimpact: confirmation required") diff --git a/internal/traceimpact/store.go b/internal/traceimpact/store.go index b28ef2bb..0f94dec8 100644 --- a/internal/traceimpact/store.go +++ b/internal/traceimpact/store.go @@ -20,6 +20,7 @@ type Store interface { ListDecisions(ctx context.Context, projectID int64) ([]Decision, error) PutReopen(ctx context.Context, req ReopenRequest) (ReopenRequest, error) GetReopen(ctx context.Context, id int64) (ReopenRequest, error) + ListPendingReopens(ctx context.Context, projectID int64) ([]ReopenRequest, error) PutMark(ctx context.Context, m RecheckMark) (RecheckMark, error) ListOpenMarks(ctx context.Context, projectID int64, stageKey string) ([]RecheckMark, error) AckMark(ctx context.Context, id, userID int64) error @@ -229,6 +230,18 @@ func (m *memStore) GetReopen(_ context.Context, id int64) (ReopenRequest, error) return req, nil } +func (m *memStore) ListPendingReopens(_ context.Context, projectID int64) ([]ReopenRequest, error) { + m.mu.Lock() + defer m.mu.Unlock() + var out []ReopenRequest + for _, req := range m.reopens { + if req.ProjectID == projectID && req.Status == ReopenPending { + out = append(out, req) + } + } + return out, nil +} + func (m *memStore) PutMark(_ context.Context, mark RecheckMark) (RecheckMark, error) { m.mu.Lock() defer m.mu.Unlock()