feat(chat): dialogue triggers for comments/reopen/pending-supplements with mistouch guards
Wire intent→coordinator DocResolver confirm cards→action_executor kinds for comment, reopen, pending-supplements query, and real submit-review execution.
This commit is contained in:
@@ -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. 锁定期补充闭环
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
+49
-46
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -394,7 +394,6 @@ func TestDialogueLLM_AmbiguousSmalltalkDoesNotUseStageSalesTemplate(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
type stubRealtimeSearcher struct {
|
||||
markdown string
|
||||
err error
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+22
-17
@@ -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}},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" // 自由任务简报(升级建议携带的用户原始诉求)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user