Files

159 lines
6.0 KiB
Go
Raw Permalink Normal View History

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
}