Files
weichen b5a782bb38 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.
2026-09-25 20:14:10 +00:00

247 lines
8.1 KiB
Go

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
}