diff --git a/internal/docgov/materialize.go b/internal/docgov/materialize.go index be733c65..bbe0fdee 100644 --- a/internal/docgov/materialize.go +++ b/internal/docgov/materialize.go @@ -3,9 +3,14 @@ package docgov import ( "context" "encoding/json" + "errors" "fmt" "strings" "time" + + "chat-agent-system/internal/workflowdef" + + "github.com/jackc/pgx/v5" ) // TemplateDocSpec 是激活后要落到 document_records 的受控文档槽。 @@ -48,6 +53,12 @@ func (e *GovernanceEngine) MaterializeTemplateDocs(ctx context.Context, projectI func (e *GovernanceEngine) upsertTemplateDoc(ctx context.Context, projectID int64, spec TemplateDocSpec) error { taskKey := strings.TrimSpace(spec.TaskKey) docID := ControlledDocID(projectID, taskKey) + if taken, err := e.hasCanonicalSibling(ctx, projectID, taskKey, docID); err != nil { + return err + } else if taken { + // 同一发布键已有另一行(常见于旧键 intake 仍是唯一数据)。不再插入 bd_* 兄弟行。 + return nil + } title := strings.TrimSpace(spec.Title) if title == "" { title = taskKey @@ -155,3 +166,51 @@ func dutyDisplayName(userID int64, name string) string { } return "" } + +func siblingTaskKeys(taskKey string) []string { + canon := workflowdef.CanonicalTaskKey(taskKey) + legacy := workflowdef.ImplementationTaskKey(canon) + var out []string + if canon != "" && canon != taskKey { + out = append(out, canon) + } + if legacy != "" && legacy != taskKey && legacy != canon { + out = append(out, legacy) + } + return out +} + +func (e *GovernanceEngine) hasCanonicalSibling(ctx context.Context, projectID int64, taskKey, docID string) (bool, error) { + siblings := siblingTaskKeys(taskKey) + if e == nil || len(siblings) == 0 { + return false, nil + } + if e.pool != nil { + var existing string + err := e.pool.QueryRow(ctx, ` + SELECT doc_id FROM document_records + WHERE project_id = $1 AND task_key = ANY($2::text[]) AND doc_id <> $3 + LIMIT 1 + `, projectID, siblings, docID).Scan(&existing) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("docgov: lookup canonical sibling: %w", err) + } + return existing != "", nil + } + e.mu.RLock() + defer e.mu.RUnlock() + for _, doc := range e.memDocs { + if doc == nil || doc.ProjectID != projectID || doc.DocID == docID { + continue + } + for _, key := range siblings { + if doc.TaskKey == key { + return true, nil + } + } + } + return false, nil +} diff --git a/internal/docgov/project_docs.go b/internal/docgov/project_docs.go index 96294223..8312edf0 100644 --- a/internal/docgov/project_docs.go +++ b/internal/docgov/project_docs.go @@ -98,7 +98,10 @@ func (e *GovernanceEngine) ListWorkflowDocumentsByProject(ctx context.Context, p } out = append(out, item) } - return out, rows.Err() + if err := rows.Err(); err != nil { + return nil, err + } + return collapseWorkflowDocuments(out), nil } e.mu.Lock() @@ -130,7 +133,7 @@ func (e *GovernanceEngine) ListWorkflowDocumentsByProject(ctx context.Context, p }) } } - return out, nil + return collapseWorkflowDocuments(out), nil } // ReadDocumentContent 读取受控文档 output.md 并返回 typed ContentError。 diff --git a/internal/docgov/workflow_doc_collapse.go b/internal/docgov/workflow_doc_collapse.go new file mode 100644 index 00000000..8752fc7a --- /dev/null +++ b/internal/docgov/workflow_doc_collapse.go @@ -0,0 +1,60 @@ +package docgov + +import ( + "sort" + + "chat-agent-system/internal/workflowdef" +) + +// collapseWorkflowDocuments 把同一发布键的旧键行与新键行收成一行。 +// 发布键优先;都是发布键时保留已脱离模板的那条;再按 doc_id 稳定选取。 +func collapseWorkflowDocuments(docs []WorkflowDocumentSummary) []WorkflowDocumentSummary { + if len(docs) < 2 { + return docs + } + best := make(map[string]WorkflowDocumentSummary, len(docs)) + for _, doc := range docs { + canon := workflowdef.CanonicalTaskKey(doc.TaskKey) + if canon == "" { + continue + } + prev, ok := best[canon] + if !ok { + best[canon] = doc + continue + } + best[canon] = preferWorkflowDocument(prev, doc) + } + out := make([]WorkflowDocumentSummary, 0, len(best)) + for _, doc := range best { + out = append(out, doc) + } + sort.Slice(out, func(i, j int) bool { + if out[i].TaskKey == out[j].TaskKey { + return out[i].DocID < out[j].DocID + } + return out[i].TaskKey < out[j].TaskKey + }) + return out +} + +func preferWorkflowDocument(current, candidate WorkflowDocumentSummary) WorkflowDocumentSummary { + currentPublished := current.TaskKey == workflowdef.CanonicalTaskKey(current.TaskKey) + candidatePublished := candidate.TaskKey == workflowdef.CanonicalTaskKey(candidate.TaskKey) + if currentPublished != candidatePublished { + if candidatePublished { + return candidate + } + return current + } + if current.IsTemplateOnly != candidate.IsTemplateOnly { + if candidate.IsTemplateOnly { + return current + } + return candidate + } + if candidate.DocID < current.DocID { + return candidate + } + return current +}