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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user