Filter private knowledge before applying shared result limits

Why:
Higher-ranked private rows could exhaust SQL candidate limits and hide an
otherwise authorized published slice from a member's current-project search.

Evidence:
SELF TestAuthorizedKnowledgeFiltersBeforeTopK failed with no shared hits before
the change and passed after it. SELF go test ./... and the isolated PostgreSQL
Store suite passed; independent replay and current-HEAD CI remain owner gates.

Impact:
Scan the resource-authorized shared fulltext path until enough allowed results
are found. Preserve ordinary legacy retrieval candidate limits and semantics.

Refs:
Dev project-member-access plan r1 Task 1; R5 published knowledge availability
This commit is contained in:
2026-10-13 08:47:19 +00:00
parent 7cf8f02399
commit b9bea5bee0
3 changed files with 72 additions and 12 deletions
+38 -9
View File
@@ -381,7 +381,11 @@ func sharedVisible(requester string, q core.RetrievalQuery, allow func(docOwner,
// 经 allow(docOwner, projectID) 放行的命中(全文通道;向量通道共享仍留后续切片)。
// allow 出错由调用方前置处理——本层只见布尔。
func (s *Store) SearchKnowledgeShared(requester string, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) {
return s.SearchKnowledgeSharedAuthorized(requester, q, func(d core.KnowledgeDoc) bool { return allow(d.Owner, d.ProjectID) })
pred := sharedVisible(requester, q, allow)
if s.dialect.name() == "postgres" {
return s.searchKnowledgePGShared(requester, q, pred)
}
return s.searchKnowledgeSQLiteShared(requester, q, pred)
}
// SearchKnowledgeSharedAuthorized evaluates the trusted document identity before
@@ -393,9 +397,16 @@ func (s *Store) SearchKnowledgeSharedAuthorized(requester string, q core.Retriev
(len(q.Sources) == 0 || containsStr(q.Sources, d.Source)) && allow(d)
}
if s.dialect.name() == "postgres" {
return s.searchKnowledgePGShared(requester, q, pred)
return s.searchKnowledgePGSharedCandidates(requester, q, pred, false)
}
return s.searchKnowledgeSQLiteShared(requester, q, pred)
out, err := s.searchKnowledgeFTSCandidates(pred, q, false)
if err != nil {
return nil, err
}
if len(out) == 0 && q.Text != "" {
return s.searchKnowledgeLikeSharedCandidates(requester, q, pred, false)
}
return out, nil
}
// DeleteProjectKnowledgeByRef replaces one publication scope and its vectors in
@@ -452,14 +463,23 @@ func (s *Store) searchKnowledgeSQLiteShared(requester string, q core.RetrievalQu
// searchKnowledgeFTS FTS 通道(owner 作用域与共享作用域共用:可见性谓词由调用方注入)。
func (s *Store) searchKnowledgeFTS(pred knowledgePred, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
return s.searchKnowledgeFTSCandidates(pred, q, true)
}
func (s *Store) searchKnowledgeFTSCandidates(pred knowledgePred, q core.RetrievalQuery, bounded bool) ([]core.RetrievalHit, error) {
topK := q.TopK
if topK <= 0 {
topK = 5
}
rows, err := s.db.Query(s.q(`
SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at, rank
FROM knowledge_fts f JOIN knowledge k ON k.rowid=f.rowid
WHERE knowledge_fts MATCH ? ORDER BY rank LIMIT ?`), ftsQuery(q.Text), topK*2)
query := `SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at, rank
FROM knowledge_fts f JOIN knowledge k ON k.rowid=f.rowid
WHERE knowledge_fts MATCH ? ORDER BY rank`
args := []any{ftsQuery(q.Text)}
if bounded {
query += " LIMIT ?"
args = append(args, topK*2)
}
rows, err := s.db.Query(s.q(query), args...)
if err != nil {
return nil, err
}
@@ -482,12 +502,21 @@ func (s *Store) searchKnowledgeFTS(pred knowledgePred, q core.RetrievalQuery) ([
}
func (s *Store) searchKnowledgeLikeShared(requester string, q core.RetrievalQuery, pred knowledgePred) ([]core.RetrievalHit, error) {
return s.searchKnowledgeLikeSharedCandidates(requester, q, pred, true)
}
func (s *Store) searchKnowledgeLikeSharedCandidates(requester string, q core.RetrievalQuery, pred knowledgePred, bounded bool) ([]core.RetrievalHit, error) {
topK := q.TopK
if topK <= 0 {
topK = 5
}
rows, err := s.db.Query(s.q(`SELECT id,owner,project_id,source,title,ref,chunk,created_at FROM knowledge WHERE owner<>? AND owner<>'' AND (chunk LIKE ? OR title LIKE ?) LIMIT ?`),
requester, "%"+q.Text+"%", "%"+q.Text+"%", topK)
query := `SELECT id,owner,project_id,source,title,ref,chunk,created_at FROM knowledge WHERE owner<>? AND owner<>'' AND (chunk LIKE ? OR title LIKE ?)`
args := []any{requester, "%" + q.Text + "%", "%" + q.Text + "%"}
if bounded {
query += " LIMIT ?"
args = append(args, topK)
}
rows, err := s.db.Query(s.q(query), args...)
if err != nil {
return nil, err
}
@@ -0,0 +1,22 @@
package store
import (
core "agent-system-core"
"testing"
)
func TestAuthorizedKnowledgeFiltersBeforeTopK(t *testing.T) {
st := openTestStore(t)
docs := []core.KnowledgeDoc{}
for i := 0; i < 20; i++ {
docs = append(docs, core.KnowledgeDoc{Owner: "alice", ProjectID: "domain", Source: "private", Chunk: "sharedterm sharedterm sharedterm sharedterm"})
}
docs = append(docs, core.KnowledgeDoc{ID: "published", Owner: "alice", ProjectID: "domain", Source: "plugin:rdm:published", Chunk: "sharedterm plus additional explanatory words for this lower scoring published source"})
if err := st.AddKnowledge(docs...); err != nil {
t.Fatal(err)
}
hits, err := st.SearchKnowledgeSharedAuthorized("bob", core.RetrievalQuery{Text: "sharedterm", ProjectID: "domain", TopK: 1}, func(d core.KnowledgeDoc) bool { return d.ID == "published" })
if err != nil || len(hits) != 1 || hits[0].Doc.ID != "published" {
t.Fatalf("denied rows consumed top-k: %+v %v", hits, err)
}
}
+12 -3
View File
@@ -53,19 +53,28 @@ func (s *Store) searchKnowledgePG(owner string, q core.RetrievalQuery) ([]core.R
// searchKnowledgePGShared EXT-8 切片 2 共享版:他人 owner 的行 + 谓词后置判定
// (allow 回调由上层注入;排序与自检索同构)。
func (s *Store) searchKnowledgePGShared(requester string, q core.RetrievalQuery, pred knowledgePred) ([]core.RetrievalHit, error) {
return s.searchKnowledgePGSharedCandidates(requester, q, pred, true)
}
func (s *Store) searchKnowledgePGSharedCandidates(requester string, q core.RetrievalQuery, pred knowledgePred, bounded bool) ([]core.RetrievalHit, error) {
topK := q.TopK
if topK <= 0 {
topK = 5
}
text := q.Text
rows, err := s.db.Query(s.q(`
query := `
SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at,
ts_rank(k.tsv, websearch_to_tsquery('simple', ?)) AS r
FROM knowledge k
WHERE (k.owner<>? AND k.owner<>'')
AND (? = '' OR k.tsv @@ websearch_to_tsquery('simple', ?) OR k.chunk LIKE ? OR k.title LIKE ?)
ORDER BY r DESC, k.created_at DESC
LIMIT ?`), text, requester, text, text, "%"+text+"%", "%"+text+"%", topK*2)
ORDER BY r DESC, k.created_at DESC`
args := []any{text, requester, text, text, "%" + text + "%", "%" + text + "%"}
if bounded {
query += " LIMIT ?"
args = append(args, topK*2)
}
rows, err := s.db.Query(s.q(query), args...)
if err != nil {
return nil, err
}