diff --git a/internal/store/content.go b/internal/store/content.go index df16303..f203824 100644 --- a/internal/store/content.go +++ b/internal/store/content.go @@ -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 } diff --git a/internal/store/knowledge_authorization_test.go b/internal/store/knowledge_authorization_test.go new file mode 100644 index 0000000..2ef80c1 --- /dev/null +++ b/internal/store/knowledge_authorization_test.go @@ -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) + } +} diff --git a/internal/store/search_postgres.go b/internal/store/search_postgres.go index 0620c9f..f5ffea5 100644 --- a/internal/store/search_postgres.go +++ b/internal/store/search_postgres.go @@ -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 }