159 lines
6.0 KiB
Go
159 lines
6.0 KiB
Go
package workbench
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"fmt"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/Tencent/WeKnora/internal/types"
|
||
|
|
"github.com/google/uuid"
|
||
|
|
"gorm.io/gorm"
|
||
|
|
"gorm.io/gorm/clause"
|
||
|
|
)
|
||
|
|
|
||
|
|
const contentMemorySourcePrefix = "内容任务/"
|
||
|
|
|
||
|
|
func contentMemorySource(resourceID, segmentID string) string {
|
||
|
|
return contentMemorySourcePrefix + resourceID + "/内容段/" + segmentID
|
||
|
|
}
|
||
|
|
|
||
|
|
func contentMemoryAdvice(advice string) string {
|
||
|
|
content := strings.Join(strings.Fields(advice), " ")
|
||
|
|
content, _ = types.RedactSensitive(content)
|
||
|
|
if types.IsMostlyRedacted(content) {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
return content
|
||
|
|
}
|
||
|
|
|
||
|
|
func currentContentFeedback(r *Resource, segments []Segment, candidate *PendingMemory) bool {
|
||
|
|
for _, segment := range segments {
|
||
|
|
if segment.Status == "accepted" && strings.TrimSpace(segment.Content) != "" &&
|
||
|
|
segment.AgentID == candidate.AgentID && candidate.SourceRef == contentMemorySource(r.ID, segment.ID) &&
|
||
|
|
candidate.Content != "" && candidate.Content == contentMemoryAdvice(segment.MemoryAdvice) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
// Candidate feedback is human-authored and remains untrusted until reviewed.
|
||
|
|
// Generated prose is kept in task revisions, never promoted into a user fact.
|
||
|
|
func (s *Service) contentFeedbackCandidate(ctx context.Context, r *Resource, segment Segment, snapshots map[string]AgentSnapshot) *PendingMemory {
|
||
|
|
if s.memories == nil || s.agents == nil || r.ID == "" || segment.Status != "accepted" || strings.TrimSpace(segment.Content) == "" {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
tenant, owner, _, err := identity(ctx)
|
||
|
|
if err != nil || tenant != r.TenantID || owner != r.OwnerID {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
content := contentMemoryAdvice(segment.MemoryAdvice)
|
||
|
|
if content == "" {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
agent, err := s.agents.GetAgentByID(ctx, segment.AgentID)
|
||
|
|
if err != nil || agent == nil || agent.TenantID != tenant || agent.Config.MemoryEnabled != nil && !*agent.Config.MemoryEnabled {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
if saved, ok := snapshots[segment.AgentID]; ok && saved.MemoryEnabled != nil && !*saved.MemoryEnabled {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
if !s.memories.MemoryAvailable(types.ApplyAgentMemoryPreference(ctx, agent.Config.MemoryEnabled)) {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
source := contentMemorySource(r.ID, segment.ID)
|
||
|
|
key := compact(JSON{"kind": "content-feedback-v1", "tenant": tenant, "owner": owner, "agent": segment.AgentID, "source": source, "content": content})
|
||
|
|
now := time.Now()
|
||
|
|
return &PendingMemory{ID: uuid.NewSHA1(uuid.NameSpaceOID, []byte(key)).String(), TenantID: tenant,
|
||
|
|
OwnerID: owner, AgentID: segment.AgentID, Content: content, SourceRef: source,
|
||
|
|
Status: "pending", CreatedAt: now, UpdatedAt: now}
|
||
|
|
}
|
||
|
|
|
||
|
|
func syncContentFeedbackMemories(tx *gorm.DB, before, after *Resource) error {
|
||
|
|
if before.Kind != "content-tasks" {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
oldData, err := decode[TaskData](before.Data)
|
||
|
|
if err != nil {
|
||
|
|
return ErrInvalid
|
||
|
|
}
|
||
|
|
newData, err := decode[TaskData](after.Data)
|
||
|
|
if err != nil {
|
||
|
|
return ErrInvalid
|
||
|
|
}
|
||
|
|
current := make(map[string]Segment, len(newData.Segments))
|
||
|
|
for _, segment := range newData.Segments {
|
||
|
|
current[segment.ID] = segment
|
||
|
|
}
|
||
|
|
var retired []string
|
||
|
|
for _, old := range oldData.Segments {
|
||
|
|
next, exists := current[old.ID]
|
||
|
|
if !exists || next.Status != "accepted" || next.AgentID != old.AgentID || next.Content != old.Content || next.MemoryAdvice != old.MemoryAdvice {
|
||
|
|
retired = append(retired, contentMemorySource(before.ID, old.ID))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(retired) > 0 {
|
||
|
|
if err := tx.Model(&PendingMemory{}).Where("tenant_id = ? AND owner_id = ? AND source_ref IN ? AND status = ?", before.TenantID, before.OwnerID, retired, "pending").Updates(map[string]any{"status": "invalidated", "updated_at": time.Now()}).Error; err != nil {
|
||
|
|
return fmt.Errorf("保存内容记忆状态失败,请重试: %w", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
candidate := after.pendingMemory
|
||
|
|
if candidate == nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
if candidate.TenantID != after.TenantID || candidate.OwnerID != after.OwnerID {
|
||
|
|
return ErrForbidden
|
||
|
|
}
|
||
|
|
if !currentContentFeedback(after, newData.Segments, candidate) {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
// Reacceptance may restore an invalidated source, but never resurrect a
|
||
|
|
// candidate the user explicitly accepted or rejected.
|
||
|
|
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}},
|
||
|
|
DoUpdates: clause.Assignments(map[string]any{"status": "pending", "updated_at": candidate.UpdatedAt}),
|
||
|
|
Where: clause.Where{Exprs: []clause.Expression{clause.Eq{Column: clause.Column{Table: clause.CurrentTable, Name: "status"}, Value: "invalidated"}}},
|
||
|
|
}).Create(candidate).Error; err != nil {
|
||
|
|
return fmt.Errorf("保存待审核记忆失败,请重试: %w", err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func invalidateDeletedContentFeedback(tx *gorm.DB, r *Resource) error {
|
||
|
|
if r.Kind != "content-tasks" {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
prefix := contentMemorySourcePrefix + r.ID + "/内容段/"
|
||
|
|
return tx.Model(&PendingMemory{}).
|
||
|
|
Where("tenant_id = ? AND owner_id = ? AND substr(source_ref, 1, ?) = ? AND status = ?", r.TenantID, r.OwnerID, len([]rune(prefix)), prefix, "pending").
|
||
|
|
Updates(map[string]any{"status": "invalidated", "updated_at": time.Now()}).Error
|
||
|
|
}
|
||
|
|
|
||
|
|
// Match the resource-save lock order so review cannot adopt deleted or rewritten feedback.
|
||
|
|
func lockContentFeedbackSource(tx *gorm.DB, row *PendingMemory) error {
|
||
|
|
if !strings.HasPrefix(row.SourceRef, contentMemorySourcePrefix) {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
parts := strings.SplitN(strings.TrimPrefix(row.SourceRef, contentMemorySourcePrefix), "/内容段/", 2)
|
||
|
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||
|
|
return ErrInvalid
|
||
|
|
}
|
||
|
|
var source Resource
|
||
|
|
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||
|
|
Where("id = ? AND kind = ? AND tenant_id = ? AND owner_id = ?", parts[0], "content-tasks", row.TenantID, row.OwnerID).Take(&source).Error
|
||
|
|
if err != nil {
|
||
|
|
if err == gorm.ErrRecordNotFound {
|
||
|
|
return fmt.Errorf("%w: 内容来源已删除,请刷新记忆列表", ErrConflict)
|
||
|
|
}
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
data, err := decode[TaskData](source.Data)
|
||
|
|
if err != nil {
|
||
|
|
return ErrInvalid
|
||
|
|
}
|
||
|
|
if !currentContentFeedback(&source, data.Segments, row) {
|
||
|
|
return fmt.Errorf("%w: 内容来源已更改,请刷新记忆列表", ErrConflict)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|