fix(docgov): collapse legacy and published task keys in the document list
列表只返回发布键那一行;物化时若旧键行已在,就不再插入兄弟行,避免撞上唯一索引。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user