WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user