Implement EXT-9 idempotent publish, prefix-enforced revoke, and the upload index switch
ci / vet-and-test (pull_request) Successful in 41s

Why: The red acceptance tests pin the EXT-9 semantics - held material must not enter retrieval until published, revoke must stop retrieval immediately and idempotently, manual sources must stay out of plugin reach.
Evidence: TestKnowledgePublishIdempotentAndRevoke and TestUploadPreIndexSwitch green; go test ./... 32/32 packages ok; rows follow on the task card.
Impact: hostcaps KnowledgePublish (per-ref row+vector cleanup then existing write path) and KnowledgeDelete (plugin:<id> prefix enforced); store DeleteKnowledgeBySource with vector cleanup; upload indexing gated by DisableUploadIndexing (default false = unchanged).
Refs: EXT-9 proposal; red run at surface commit.
This commit is contained in:
2026-10-08 15:55:02 +00:00
parent 1ee32822c3
commit fbc924c240
3 changed files with 74 additions and 10 deletions
+32 -5
View File
@@ -3,7 +3,6 @@ package app
import (
"agent-system-backend/pkg/extension"
"context"
"errors"
"fmt"
"sync"
@@ -140,14 +139,42 @@ func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc
return h.caps.pipe.IndexChunks(ctx, core.ID(h.owner), docs)
}
// KnowledgePublish/KnowledgeDelete EXT-9 幂等发布与撤销(本提交为占位——
// 红测先行,实现在后续提交落地)。
// KnowledgePublish 幂等发布(EXT-9):按 docs 的 ref 整批替换——先清该 owner
// 下同 ref 旧行与旧向量(跨来源也清:ref 是发布单元),再走 KnowledgeWrite
// 语义(Owner/Source 强制同彼)。重试产生相同结果。
func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error {
return errors.New("ext-9: knowledge publish not implemented yet")
if len(docs) == 0 {
return nil
}
refs := map[string]bool{}
for i := range docs {
docs[i].Owner = core.ID(h.owner)
if docs[i].Source == "" {
docs[i].Source = "plugin:" + h.pluginID
}
if docs[i].Ref != "" {
refs[docs[i].Ref] = true
}
}
for ref := range refs {
if err := h.caps.st.DeleteKnowledgeByRef(h.owner, ref); err != nil {
return err
}
if err := h.caps.st.DeleteKnowledgeVectors(ctx, h.owner, ref); err != nil {
return err
}
}
return h.KnowledgeWrite(ctx, docs, embed)
}
// KnowledgeDelete 撤销本插件来源(EXT-9):前缀强制 plugin:<自身ID>——manual
// 或他人来源拒绝(fail-closed);删行+向量,幂等。
func (h inprocHost) KnowledgeDelete(ctx context.Context, sourcePrefix string) error {
return errors.New("ext-9: knowledge delete not implemented yet")
if sourcePrefix != "plugin:"+h.pluginID {
return fmt.Errorf("ext-9: plugins may only delete their own source prefix, want %q", "plugin:"+h.pluginID)
}
_, err := h.caps.st.DeleteKnowledgeBySource(ctx, h.owner, sourcePrefix)
return err
}
// TaskCreate/TaskUpdate 后台任务台账:基座只记进度/状态,执行体由下游自跑(H5)。
+8 -5
View File
@@ -1058,11 +1058,14 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
_ = s.Cards.Update(owner, ingestCard)
}
// 文本可解析的进检索索引(管线分块+全文+可选向量)。
if plain := doc.PlainText(); plain != "" && len(plain) < 200000 {
if s.Indexer != nil {
_ = s.Indexer.IndexDocument(owner, projectID, "upload", art.Name, art.ID, plain)
} else {
_ = s.st.AddKnowledge(core.KnowledgeDoc{Owner: owner, ProjectID: projectID, Source: "upload", Title: art.Name, Ref: art.ID, Chunk: plain})
// EXT-9:宿主关闭自动索引时跳过(发布决定移交下游命令层;原件与摄取卡不变)。
if !s.DisableUploadIndexing {
if plain := doc.PlainText(); plain != "" && len(plain) < 200000 {
if s.Indexer != nil {
_ = s.Indexer.IndexDocument(owner, projectID, "upload", art.Name, art.ID, plain)
} else {
_ = s.st.AddKnowledge(core.KnowledgeDoc{Owner: owner, ProjectID: projectID, Source: "upload", Title: art.Name, Ref: art.ID, Chunk: plain})
}
}
}
writeJSON(w, http.StatusOK, core.UploadInfo{ArtifactID: art.ID, Filename: art.Name, MIME: mime, Size: art.Size, Parsed: &doc})
+34
View File
@@ -312,6 +312,40 @@ func (s *Store) DeleteKnowledgeByRef(owner, ref string) error {
return err
}
// DeleteKnowledgeBySource 按来源前缀撤销知识(EXT-9):删除该 owner 下 source 匹配
// 前缀的行,并经 ref 关联清理向量;幂等(无匹配返回 0, nil)。供宿主在
// KnowledgeDelete(前缀已强制 plugin:<插件ID>)后调用——不作用他人来源。
func (s *Store) DeleteKnowledgeBySource(ctx context.Context, owner, sourcePrefix string) (int64, error) {
rows, err := s.db.QueryContext(ctx, s.q(`SELECT DISTINCT ref FROM knowledge WHERE owner=? AND source LIKE ?`), owner, sourcePrefix+"%")
if err != nil {
return 0, err
}
refs := []string{}
for rows.Next() {
var r string
if err := rows.Scan(&r); err != nil {
rows.Close()
return 0, err
}
refs = append(refs, r)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
for _, r := range refs {
if err := s.DeleteKnowledgeVectors(ctx, owner, r); err != nil {
return 0, err
}
}
res, err := s.db.ExecContext(ctx, s.q(`DELETE FROM knowledge WHERE owner=? AND source LIKE ?`), owner, sourcePrefix+"%")
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return n, nil
}
func (s *Store) SearchKnowledge(owner string, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
if s.dialect.name() == "postgres" {
return s.searchKnowledgePG(owner, q)