Files
Agent-System/internal/docgov/engine_proposal.go
T
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

393 lines
12 KiB
Go

package docgov
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
)
// ProposeModification 当文档处于已认领状态时,由智能体或系统提出变更提案
func (e *GovernanceEngine) ProposeModification(
ctx context.Context,
docID string,
triggeredBy string,
proposedChanges string,
impactAnalysis string,
riskAssessment string,
) (*ChangeProposal, error) {
g := e.acquireDocLock(docID)
defer g.Release()
doc, err := e.ensureDocLockedWithDB(ctx, docID)
if err != nil {
return nil, err
}
proposalID := fmt.Sprintf("prop_%s_%d", doc.TaskKey, time.Now().UnixNano())
now := time.Now()
proposal := &ChangeProposal{
ProposalID: proposalID,
DocID: docID,
ProjectID: doc.ProjectID,
TriggeredBy: triggeredBy,
ProposedChanges: proposedChanges,
ImpactAnalysis: impactAnalysis,
RiskAssessment: riskAssessment,
Status: ProposalPendingAuthorApproval,
CreatedAt: now,
}
e.memProps[proposalID] = proposal
// 记录审计
audit := &AuditLog{
DocID: docID,
Action: "propose_change",
ActorID: 0,
ActorName: triggeredBy,
ActorRole: RoleSystem,
FromState: doc.CurrentState,
ToState: doc.CurrentState,
Details: map[string]any{
"proposal_id": proposalID,
"proposed_changes": proposedChanges,
"impact": impactAnalysis,
"risk": riskAssessment,
},
CreatedAt: now,
}
e.memAudits[docID] = append(e.memAudits[docID], audit)
if e.pool != nil {
query := `
INSERT INTO document_change_proposals (
proposal_id, doc_id, project_id, triggered_by, proposed_changes, impact_analysis, risk_assessment, status, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id;
`
var id int64
if err := e.pool.QueryRow(ctx, query,
proposal.ProposalID, proposal.DocID, proposal.ProjectID,
proposal.TriggeredBy, proposal.ProposedChanges, proposal.ImpactAnalysis, proposal.RiskAssessment,
string(proposal.Status), proposal.CreatedAt,
).Scan(&id); err == nil {
proposal.ID = id
}
detJSON, _ := json.Marshal(audit.Details)
if _, err := e.pool.Exec(ctx, `
INSERT INTO document_audit_logs (doc_id, action, actor_id, actor_name, actor_role, from_state, to_state, details, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`, audit.DocID, audit.Action, audit.ActorID, audit.ActorName, string(audit.ActorRole),
string(audit.FromState), string(audit.ToState), detJSON, audit.CreatedAt); err != nil {
slog.Error("docgov_audit_persist_failed", "doc_id", audit.DocID, "action", audit.Action, "error", err)
}
}
return proposal, nil
}
// DecideModificationProposal 编制负责人拍板决定:同意修改或拒绝修改
func (e *GovernanceEngine) DecideModificationProposal(
ctx context.Context,
proposalID string,
authorID int64,
approve bool,
reason string,
) (*ChangeProposal, error) {
// 提案级锁:按 proposalID 串行化拍板决策。
g := e.acquireDocLock("*proposal:" + proposalID)
defer g.Release()
proposal, exists := e.memProps[proposalID]
if !exists && e.pool != nil {
var p ChangeProposal
var stat string
err := e.pool.QueryRow(ctx, `
SELECT id, proposal_id, doc_id, project_id, triggered_by, proposed_changes, impact_analysis, risk_assessment, status, author_decision_reason, decided_at, created_at
FROM document_change_proposals
WHERE proposal_id = $1
`, proposalID).Scan(&p.ID, &p.ProposalID, &p.DocID, &p.ProjectID, &p.TriggeredBy, &p.ProposedChanges, &p.ImpactAnalysis, &p.RiskAssessment, &stat, &p.AuthorDecisionReason, &p.DecidedAt, &p.CreatedAt)
if err == nil {
p.Status = ProposalStatus(stat)
proposal = &p
e.memProps[proposalID] = &p
exists = true
}
}
if !exists {
return nil, ErrProposalNotFound
}
if proposal.Status != ProposalPendingAuthorApproval {
return nil, ErrProposalAlreadyDecided
}
doc, docExists := e.memDocs[proposal.DocID]
if !docExists && e.pool != nil {
var err error
doc, err = e.loadDocumentFromDBLocked(ctx, proposal.DocID)
if err == nil && doc != nil {
docExists = true
}
}
if !docExists {
return nil, ErrDocumentNotFound
}
// 校验只有编制人有权拍板
if doc.AuthorID > 0 && authorID > 0 && doc.AuthorID != authorID {
return nil, fmt.Errorf("%w: 只有编制负责人 (ID: %d) 有权拍板该修改提案", ErrUnauthorizedActor, doc.AuthorID)
}
now := time.Now()
proposal.AuthorDecisionReason = strings.TrimSpace(reason)
proposal.DecidedAt = &now
var action string
if approve {
proposal.Status = ProposalApprovedByAuthor
action = "author_approve_proposal"
} else {
proposal.Status = ProposalRejectedByAuthor
action = "author_reject_proposal"
}
audit := &AuditLog{
DocID: proposal.DocID,
Action: action,
ActorID: authorID,
ActorName: doc.AuthorName,
ActorRole: RoleAuthor,
FromState: doc.CurrentState,
ToState: doc.CurrentState,
Details: map[string]any{
"proposal_id": proposalID,
"decision": approve,
"reason": proposal.AuthorDecisionReason,
"timestamp": now,
},
CreatedAt: now,
}
e.memAudits[proposal.DocID] = append(e.memAudits[proposal.DocID], audit)
if e.pool != nil {
if _, err := e.pool.Exec(ctx, `
UPDATE document_change_proposals
SET status = $1, author_decision_reason = $2, decided_at = $3
WHERE proposal_id = $4
`, string(proposal.Status), proposal.AuthorDecisionReason, proposal.DecidedAt, proposalID); err != nil {
slog.Error("docgov_proposal_decide_persist_failed", "proposal_id", proposalID, "error", err)
}
detJSON, _ := json.Marshal(audit.Details)
if _, err := e.pool.Exec(ctx, `
INSERT INTO document_audit_logs (doc_id, action, actor_id, actor_name, actor_role, from_state, to_state, details, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`, audit.DocID, audit.Action, audit.ActorID, audit.ActorName, string(audit.ActorRole),
string(audit.FromState), string(audit.ToState), detJSON, audit.CreatedAt); err != nil {
slog.Error("docgov_audit_persist_failed", "doc_id", audit.DocID, "action", audit.Action, "error", err)
}
}
return proposal, nil
}
// roleAllowedToComment enforces the stage-scoped comment matrix.
func roleAllowedToComment(state DocumentState, role UserRole) bool {
switch state {
case StateDrafting:
return role == RoleAuthor || role == RoleReviewer || role == RoleSystem
case StateReviewing:
return role == RoleReviewer || role == RoleAuthor || role == RoleSystem
case StateApproving:
return role == RoleApprover || role == RoleReviewer || role == RoleSystem
case StateApproved:
return false
default:
return false
}
}
// AddComment 添加批注与修改意见。有效编辑租约期间禁止批注;生命周期锁定下仍允许批注(按阶段矩阵)。
func (e *GovernanceEngine) AddComment(
ctx context.Context,
docID string,
userID int64,
userName string,
userRole UserRole,
targetSection string,
commentType CommentType,
content string,
) (*DocumentComment, error) {
if err := e.AssertWritableForComment(ctx, docID); err != nil {
return nil, err
}
g := e.acquireDocLock(docID)
defer g.Release()
doc, exists := e.memDocs[docID]
if !exists {
var err error
doc, err = e.loadDocumentFromDBLocked(ctx, docID)
if err != nil || doc == nil {
return nil, ErrDocumentNotFound
}
}
if doc.CurrentState == StateApproved {
return nil, fmt.Errorf("%w: 已批准归档文档禁止新增批注", ErrDocumentLocked)
}
if !roleAllowedToComment(doc.CurrentState, userRole) {
return nil, fmt.Errorf("%w: 当前阶段【%s】不允许角色【%s】新增批注", ErrUnauthorizedActor, doc.CurrentState, userRole)
}
trimmed := strings.TrimSpace(content)
if trimmed == "" {
return nil, fmt.Errorf("comment content cannot be empty")
}
now := time.Now()
comment := &DocumentComment{
DocID: docID,
UserID: userID,
UserName: userName,
UserRole: userRole,
TargetSection: targetSection,
CommentType: commentType,
Content: trimmed,
Status: "open",
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 {
query := `
INSERT INTO document_comments (doc_id, user_id, user_name, user_role, target_section, comment_type, content, status, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id;
`
var id int64
if err := e.pool.QueryRow(ctx, query,
comment.DocID, comment.UserID, comment.UserName, string(comment.UserRole),
comment.TargetSection, string(comment.CommentType), comment.Content, comment.Status, comment.CreatedAt,
).Scan(&id); err == nil {
comment.ID = id
}
}
return comment, nil
}
// ResolveComment marks a comment resolved (author in drafting) or reopens it (reviewer).
func (e *GovernanceEngine) ResolveComment(ctx context.Context, commentID int64, actorID int64, actorRole UserRole, resolve bool) (*DocumentComment, error) {
if e == nil || commentID <= 0 {
return nil, fmt.Errorf("docgov: comment id required")
}
e.mu.Lock()
defer e.mu.Unlock()
var found *DocumentComment
var docID string
for id, list := range e.memComms {
for _, c := range list {
if c != nil && c.ID == commentID {
found = c
docID = id
break
}
}
if found != nil {
break
}
}
if found == nil && e.pool != nil {
var c DocumentComment
var role, cType string
err := e.pool.QueryRow(ctx, `
SELECT id, doc_id, user_id, user_name, user_role, target_section, comment_type, content, status, created_at
FROM document_comments WHERE id = $1
`, commentID).Scan(
&c.ID, &c.DocID, &c.UserID, &c.UserName, &role, &c.TargetSection, &cType, &c.Content, &c.Status, &c.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("docgov: comment not found")
}
c.UserRole = UserRole(role)
c.CommentType = CommentType(cType)
found = &c
docID = c.DocID
e.memComms[docID] = append(e.memComms[docID], found)
}
if found == nil {
return nil, fmt.Errorf("docgov: comment not found")
}
doc, exists := e.memDocs[docID]
if !exists {
var err error
doc, err = e.loadDocumentFromDBLocked(ctx, docID)
if err != nil || doc == nil {
return nil, ErrDocumentNotFound
}
}
newStatus := "resolved"
if !resolve {
newStatus = "open"
if actorRole != RoleReviewer && actorRole != RoleSystem {
return nil, fmt.Errorf("%w: 仅审核人可重开批注", ErrUnauthorizedActor)
}
} else {
switch {
case actorRole == RoleAuthor && doc.CurrentState == StateDrafting:
case actorRole == RoleReviewer || actorRole == RoleSystem:
default:
return nil, fmt.Errorf("%w: 当前阶段不允许将该批注标记为已处理", ErrUnauthorizedActor)
}
}
found.Status = newStatus
if e.pool != nil {
_, err := e.pool.Exec(ctx, `UPDATE document_comments SET status = $1 WHERE id = $2`, newStatus, commentID)
if err != nil {
return nil, fmt.Errorf("docgov: resolve comment: %w", err)
}
}
_ = actorID
return found, nil
}
func (e *GovernanceEngine) addCommentInternal(docID string, userID int64, userName string, userRole UserRole, targetSection string, commentType CommentType, content string) error {
now := time.Now()
comment := &DocumentComment{
DocID: docID,
UserID: userID,
UserName: userName,
UserRole: userRole,
TargetSection: targetSection,
CommentType: commentType,
Content: content,
Status: "open",
CreatedAt: now,
}
e.memComms[docID] = append(e.memComms[docID], comment)
return nil
}