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
639 lines
20 KiB
Go
639 lines
20 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"time"
|
||
|
||
core "agent-system-core"
|
||
)
|
||
|
||
// ---- 工件(元数据在库,内容在对象目录 data/artifacts/<id>)----
|
||
// 文件写走 临时文件 → rename → 落库:崩溃残留只可能产生无主孤立文件,
|
||
// 不产生库里有行、对象缺失的悬空引用。
|
||
|
||
func (s *Store) CreateArtifact(a *core.Artifact, content []byte) error {
|
||
if a.ID == "" {
|
||
a.ID = newID("art")
|
||
}
|
||
now := time.Now().UTC()
|
||
a.CreatedAt, a.UpdatedAt = now, now
|
||
a.Size = int64(len(content))
|
||
ref, err := s.blobs.Put(a.ID, content)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, err = s.db.Exec(s.q(`INSERT INTO artifacts(id,project_id,owner,name,kind,mime,size,source_run,content_path,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`),
|
||
a.ID, a.ProjectID, a.Owner, a.Name, a.Kind, a.MIME, a.Size, a.SourceRun, ref, nowISO(), nowISO())
|
||
if err != nil {
|
||
_ = s.blobs.Delete(ref) // 落库失败:回收对象,不留悬空
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Store) UpdateArtifactContent(owner, id string, content []byte) error {
|
||
var path string
|
||
err := s.db.QueryRow(s.q(`SELECT content_path FROM artifacts WHERE id=? AND owner=?`), id, owner).Scan(&path)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// 同 ID 原子覆写(临时文件+rename);行内引用不变,避免行/对象漂移。
|
||
if _, err := s.blobs.Put(id, content); err != nil {
|
||
return err
|
||
}
|
||
res, err := s.db.Exec(s.q(`UPDATE artifacts SET size=?, updated_at=? WHERE id=? AND owner=?`), len(content), nowISO(), id, owner)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
const artifactColumns = `id,project_id,owner,name,kind,mime,size,source_run,created_at,updated_at`
|
||
|
||
func scanArtifact(scan func(...any) error) (core.Artifact, error) {
|
||
var a core.Artifact
|
||
var owner, mime, sourceRun string
|
||
var ca, ua string
|
||
if err := scan(&a.ID, &a.ProjectID, &owner, &a.Name, &a.Kind, &mime, &a.Size, &sourceRun, &ca, &ua); err != nil {
|
||
return a, err
|
||
}
|
||
a.Owner, a.MIME, a.SourceRun = owner, mime, sourceRun
|
||
a.CreatedAt, _ = parseTime(ca)
|
||
a.UpdatedAt, _ = parseTime(ua)
|
||
return a, nil
|
||
}
|
||
|
||
func (s *Store) GetArtifact(owner, id string) (core.Artifact, error) {
|
||
a, err := scanArtifact(func(dst ...any) error {
|
||
return s.db.QueryRow(s.q(`SELECT `+artifactColumns+` FROM artifacts WHERE id=? AND owner=?`), id, owner).Scan(dst...)
|
||
})
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return a, ErrNotFound
|
||
}
|
||
return a, err
|
||
}
|
||
|
||
func (s *Store) ArtifactContent(owner, id string) ([]byte, error) {
|
||
var ref string
|
||
err := s.db.QueryRow(s.q(`SELECT content_path FROM artifacts WHERE id=? AND owner=?`), id, owner).Scan(&ref)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.blobs.Get(ref)
|
||
}
|
||
|
||
func (s *Store) ListArtifacts(owner, projectID string) ([]core.Artifact, error) {
|
||
rows, err := s.db.Query(s.q(`SELECT `+artifactColumns+` FROM artifacts WHERE project_id=? AND owner=? ORDER BY updated_at DESC`), projectID, owner)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := []core.Artifact{}
|
||
for rows.Next() {
|
||
a, err := scanArtifact(rows.Scan)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, a)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// ---- EXT-8 共享读取助手:无 owner 过滤,仅供 HTTP 层在
|
||
// authorizeSharedProject 判定通过后使用;其他调用点一律禁止(会破坏租户边界)。----
|
||
|
||
// GetArtifactByID 按工件 ID 取元数据(无 owner 过滤;调用方须先过 EXT-8 判定)。
|
||
func (s *Store) GetArtifactByID(id string) (core.Artifact, error) {
|
||
a, err := scanArtifact(func(dst ...any) error {
|
||
return s.db.QueryRow(s.q(`SELECT `+artifactColumns+` FROM artifacts WHERE id=?`), id).Scan(dst...)
|
||
})
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return a, ErrNotFound
|
||
}
|
||
return a, err
|
||
}
|
||
|
||
// ArtifactContentByID 按工件 ID 取内容(无 owner 过滤;调用方须先过 EXT-8 判定)。
|
||
func (s *Store) ArtifactContentByID(id string) ([]byte, error) {
|
||
var ref string
|
||
err := s.db.QueryRow(s.q(`SELECT content_path FROM artifacts WHERE id=?`), id).Scan(&ref)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.blobs.Get(ref)
|
||
}
|
||
|
||
// ListProjectArtifactsAll 项目全部工件(含其他 owner;调用方须先过 EXT-8 判定)。
|
||
func (s *Store) ListProjectArtifactsAll(projectID string) ([]core.Artifact, error) {
|
||
rows, err := s.db.Query(s.q(`SELECT `+artifactColumns+` FROM artifacts WHERE project_id=? ORDER BY updated_at DESC`), projectID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := []core.Artifact{}
|
||
for rows.Next() {
|
||
a, err := scanArtifact(rows.Scan)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, a)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// DeleteArtifact 先删行后删对象:崩溃间隙只可能残留无主文件(可 GC),不悬空引用。
|
||
func (s *Store) DeleteArtifact(owner, id string) error {
|
||
var path string
|
||
err := s.db.QueryRow(s.q(`SELECT content_path FROM artifacts WHERE id=? AND owner=?`), id, owner).Scan(&path)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if _, err := s.db.Exec(s.q(`DELETE FROM artifacts WHERE id=? AND owner=?`), id, owner); err != nil {
|
||
return err
|
||
}
|
||
// 联动清理检索索引(知识行 + 向量;失败不阻断删除,残留可由重建索引收敛)。
|
||
_ = s.DeleteKnowledgeByRef(owner, id)
|
||
_ = s.DeleteKnowledgeVectors(context.Background(), owner, id)
|
||
return s.blobs.Delete(path)
|
||
}
|
||
|
||
// ---- HITL 卡片(resolve 原子化:条件更新消灭先查后改窗口)----
|
||
|
||
func (s *Store) SaveHITLCard(owner string, c *core.HITLCard) error {
|
||
_, err := s.db.Exec(s.q(`INSERT INTO hitl_cards(id,owner,run_id,conversation_id,payload,created_at,resolved,resolution) VALUES(?,?,?,?,?,?,?,?)
|
||
ON CONFLICT(id) DO UPDATE SET payload=excluded.payload, resolved=excluded.resolved, resolution=excluded.resolution`),
|
||
c.ID, owner, c.RunID, c.ConversationID, marshalJSON(c), nowISO(), boolInt(c.Resolved), "")
|
||
return err
|
||
}
|
||
|
||
func (s *Store) GetHITLCard(owner, id string) (core.HITLCard, error) {
|
||
var payload string
|
||
err := s.db.QueryRow(s.q(`SELECT payload FROM hitl_cards WHERE id=? AND owner=?`), id, owner).Scan(&payload)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return core.HITLCard{}, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return core.HITLCard{}, err
|
||
}
|
||
return unmarshalJSON[core.HITLCard](payload)
|
||
}
|
||
|
||
// ResolveHITLCard 原子置为已解决;已解决的重复请求返回明确错误(先查后改竞态修复)。
|
||
func (s *Store) ResolveHITLCard(owner, id string, resolution core.HITLResolvedPayload) error {
|
||
c, err := s.GetHITLCard(owner, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if c.Resolved {
|
||
return fmt.Errorf("store: card %s already resolved", id)
|
||
}
|
||
c.Resolved = true
|
||
res, err := s.db.Exec(s.q(`UPDATE hitl_cards SET resolved=1, payload=?, resolution=? WHERE id=? AND owner=? AND resolved=0`),
|
||
marshalJSON(c), marshalJSON(resolution), id, owner)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
return fmt.Errorf("store: card %s already resolved", id)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Store) ActiveHITLCards(owner, conversationID string) ([]core.HITLCard, error) {
|
||
rows, err := s.db.Query(s.q(`SELECT payload FROM hitl_cards WHERE conversation_id=? AND owner=? AND resolved=0 ORDER BY created_at`), conversationID, owner)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := []core.HITLCard{}
|
||
for rows.Next() {
|
||
var payload string
|
||
if err := rows.Scan(&payload); err != nil {
|
||
return nil, err
|
||
}
|
||
c, _ := unmarshalJSON[core.HITLCard](payload)
|
||
out = append(out, c)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func boolInt(b bool) int {
|
||
if b {
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// ---- 计划 / 待办(会话域;归属由上游 GetConversation 校验)----
|
||
|
||
func (s *Store) SavePlan(conversationID string, p core.PlanSnapshot) error {
|
||
_, err := s.db.Exec(s.q(`INSERT INTO plans(conversation_id,payload) VALUES(?,?)
|
||
ON CONFLICT(conversation_id) DO UPDATE SET payload=excluded.payload`), conversationID, marshalJSON(p))
|
||
return err
|
||
}
|
||
|
||
func (s *Store) GetPlan(conversationID string) (core.PlanSnapshot, error) {
|
||
var payload string
|
||
err := s.db.QueryRow(s.q(`SELECT payload FROM plans WHERE conversation_id=?`), conversationID).Scan(&payload)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return core.PlanSnapshot{}, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return core.PlanSnapshot{}, err
|
||
}
|
||
return unmarshalJSON[core.PlanSnapshot](payload)
|
||
}
|
||
|
||
func (s *Store) SaveTodos(conversationID string, l core.TodoList) error {
|
||
_, err := s.db.Exec(s.q(`INSERT INTO todos(conversation_id,payload) VALUES(?,?)
|
||
ON CONFLICT(conversation_id) DO UPDATE SET payload=excluded.payload`), conversationID, marshalJSON(l))
|
||
return err
|
||
}
|
||
|
||
func (s *Store) GetTodos(conversationID string) (core.TodoList, error) {
|
||
var payload string
|
||
err := s.db.QueryRow(s.q(`SELECT payload FROM todos WHERE conversation_id=?`), conversationID).Scan(&payload)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return core.TodoList{}, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return core.TodoList{}, err
|
||
}
|
||
return unmarshalJSON[core.TodoList](payload)
|
||
}
|
||
|
||
// ---- 知识(全文检索:SQLite FTS5 / PG tsvector+trigram;owner='' 为共享知识)----
|
||
|
||
// AddKnowledge 单事务批量写入:中途失败整体回滚,不再产生半批导入。
|
||
func (s *Store) AddKnowledge(docs ...core.KnowledgeDoc) error {
|
||
if len(docs) == 0 {
|
||
return nil
|
||
}
|
||
tx, err := s.db.Begin()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer tx.Rollback()
|
||
ts := nowISO()
|
||
// 按下标写回生成的 ID:调用方(rag 管线)用 ID 关联向量。
|
||
for i := range docs {
|
||
if docs[i].ID == "" {
|
||
docs[i].ID = newID("knw")
|
||
}
|
||
if _, err := tx.Exec(s.q(`INSERT INTO knowledge(id,owner,project_id,source,title,ref,chunk,created_at) VALUES(?,?,?,?,?,?,?,?)`),
|
||
docs[i].ID, docs[i].Owner, docs[i].ProjectID, docs[i].Source, docs[i].Title, docs[i].Ref, docs[i].Chunk, ts); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return tx.Commit()
|
||
}
|
||
|
||
// DeleteKnowledgeByRef 删除引用对应的知识(工件删除时联动清索引)。
|
||
func (s *Store) DeleteKnowledgeByRef(owner, ref string) error {
|
||
_, err := s.db.Exec(s.q(`DELETE FROM knowledge WHERE ref=? AND owner=?`), ref, owner)
|
||
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)
|
||
}
|
||
return s.searchKnowledgeSQLite(owner, q)
|
||
}
|
||
|
||
// knowledgePred 命中可见性谓词(owner 作用域或共享作用域由调用方决定)。
|
||
type knowledgePred func(d core.KnowledgeDoc) bool
|
||
|
||
func ownerVisible(owner string, q core.RetrievalQuery) knowledgePred {
|
||
return func(d core.KnowledgeDoc) bool { return knowledgeVisible(owner, q, d) }
|
||
}
|
||
|
||
// sharedVisible EXT-8 切片 2:他人 owner 且其 ProjectID 经授权回调放行的行可见;
|
||
// 无 ProjectID 的行无法授权(拒绝);项目/来源过滤沿用查询口径。
|
||
func sharedVisible(requester string, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) knowledgePred {
|
||
return func(d core.KnowledgeDoc) bool {
|
||
if d.Owner == "" || d.Owner == requester || d.ProjectID == "" {
|
||
return false
|
||
}
|
||
if q.ProjectID != "" && d.ProjectID != q.ProjectID {
|
||
return false
|
||
}
|
||
if len(q.Sources) > 0 && !containsStr(q.Sources, d.Source) {
|
||
return false
|
||
}
|
||
return allow(d.Owner, d.ProjectID)
|
||
}
|
||
}
|
||
|
||
// SearchKnowledgeShared 共享检索(EXT-8 切片 2):返回他人 owner 的项目知识中,
|
||
// 经 allow(docOwner, projectID) 放行的命中(全文通道;向量通道共享仍留后续切片)。
|
||
// allow 出错由调用方前置处理——本层只见布尔。
|
||
func (s *Store) SearchKnowledgeShared(requester string, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) {
|
||
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
|
||
// returning a shared row. Project-level permissions cannot grant private content.
|
||
func (s *Store) SearchKnowledgeSharedAuthorized(requester string, q core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) {
|
||
pred := func(d core.KnowledgeDoc) bool {
|
||
return d.Owner != "" && d.Owner != requester && d.ProjectID != "" &&
|
||
(q.ProjectID == "" || d.ProjectID == q.ProjectID) &&
|
||
(len(q.Sources) == 0 || containsStr(q.Sources, d.Source)) && allow(d)
|
||
}
|
||
if s.dialect.name() == "postgres" {
|
||
return s.searchKnowledgePGSharedCandidates(requester, q, pred, false)
|
||
}
|
||
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
|
||
// a single transaction. Empty project is the legacy unscoped publication domain.
|
||
func (s *Store) DeleteProjectKnowledgeByRef(ctx context.Context, owner, projectID, ref string) error {
|
||
tx, err := s.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer tx.Rollback()
|
||
args := []any{owner, ref}
|
||
pred := "owner=? AND ref=?"
|
||
if projectID != "" {
|
||
pred += " AND project_id=?"
|
||
args = append(args, projectID)
|
||
}
|
||
if _, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge_vectors WHERE doc_id IN (SELECT id FROM knowledge WHERE "+pred+")"), args...); err != nil {
|
||
return err
|
||
}
|
||
if _, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge WHERE "+pred), args...); err != nil {
|
||
return err
|
||
}
|
||
return tx.Commit()
|
||
}
|
||
|
||
func (s *Store) searchKnowledgeSQLite(owner string, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||
out, err := s.searchKnowledgeFTS(ownerVisible(owner, q), q)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// CJK 兜底:unicode61 分词把中文整句当单 token,FTS 前缀匹配失效时用 LIKE 子串。
|
||
if len(out) == 0 && q.Text != "" {
|
||
topK := q.TopK
|
||
if topK <= 0 {
|
||
topK = 5
|
||
}
|
||
return s.searchKnowledgeLike(owner, q, topK)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// searchKnowledgeSQLiteShared 共享版:FTS SQL 本身无 owner 预过滤(可见性原为
|
||
// 后置判定),直接换谓词即可;LIKE 兜底换“他人行”预过滤。
|
||
func (s *Store) searchKnowledgeSQLiteShared(requester string, q core.RetrievalQuery, pred knowledgePred) ([]core.RetrievalHit, error) {
|
||
out, err := s.searchKnowledgeFTS(pred, q)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(out) == 0 && q.Text != "" {
|
||
return s.searchKnowledgeLikeShared(requester, q, pred)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// 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
|
||
}
|
||
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
|
||
}
|
||
defer rows.Close()
|
||
out := []core.RetrievalHit{}
|
||
for rows.Next() {
|
||
h, err := scanKnowledgeHit(rows)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !pred(h.Doc) {
|
||
continue
|
||
}
|
||
out = append(out, h)
|
||
if len(out) >= topK {
|
||
break
|
||
}
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
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
|
||
}
|
||
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
|
||
}
|
||
defer rows.Close()
|
||
out := []core.RetrievalHit{}
|
||
for rows.Next() {
|
||
var d core.KnowledgeDoc
|
||
var own, ca string
|
||
if err := rows.Scan(&d.ID, &own, &d.ProjectID, &d.Source, &d.Title, &d.Ref, &d.Chunk, &ca); err != nil {
|
||
return nil, err
|
||
}
|
||
d.Owner = own
|
||
d.CreatedAt, _ = parseTime(ca)
|
||
if !pred(d) {
|
||
continue
|
||
}
|
||
out = append(out, core.RetrievalHit{Doc: d, Score: 1.0, Kind: "fulltext"})
|
||
if len(out) >= topK {
|
||
break
|
||
}
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (s *Store) searchKnowledgeLike(owner string, q core.RetrievalQuery, topK int) ([]core.RetrievalHit, error) {
|
||
rows, err := s.db.Query(s.q(`SELECT id,owner,project_id,source,title,ref,chunk,created_at FROM knowledge WHERE (owner=? OR owner='') AND (chunk LIKE ? OR title LIKE ?) LIMIT ?`),
|
||
owner, "%"+q.Text+"%", "%"+q.Text+"%", topK)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := []core.RetrievalHit{}
|
||
for rows.Next() {
|
||
var d core.KnowledgeDoc
|
||
var own, ca string
|
||
if err := rows.Scan(&d.ID, &own, &d.ProjectID, &d.Source, &d.Title, &d.Ref, &d.Chunk, &ca); err != nil {
|
||
return nil, err
|
||
}
|
||
d.Owner = own
|
||
d.CreatedAt, _ = parseTime(ca)
|
||
if !knowledgeVisible(owner, q, d) {
|
||
continue
|
||
}
|
||
out = append(out, core.RetrievalHit{Doc: d, Score: 1.0, Kind: "fulltext"})
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// knowledgeVisible 共同过滤:租户(本人 + 共享)、项目、来源。
|
||
func knowledgeVisible(owner string, q core.RetrievalQuery, d core.KnowledgeDoc) bool {
|
||
if d.Owner != "" && d.Owner != owner {
|
||
return false
|
||
}
|
||
if q.ProjectID != "" && d.ProjectID != "" && d.ProjectID != q.ProjectID {
|
||
return false
|
||
}
|
||
if len(q.Sources) > 0 && !containsStr(q.Sources, d.Source) {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
type rowScanner interface{ Scan(dest ...any) error }
|
||
|
||
func scanKnowledgeHit(rows rowScanner) (core.RetrievalHit, error) {
|
||
var d core.KnowledgeDoc
|
||
var own, ca string
|
||
var score float64
|
||
if err := rows.Scan(&d.ID, &own, &d.ProjectID, &d.Source, &d.Title, &d.Ref, &d.Chunk, &ca, &score); err != nil {
|
||
return core.RetrievalHit{}, err
|
||
}
|
||
d.Owner = own
|
||
d.CreatedAt, _ = parseTime(ca)
|
||
return core.RetrievalHit{Doc: d, Score: -score, Kind: "fulltext"}, nil
|
||
}
|
||
|
||
// ftsQuery 做最小安全化:按空白切词加引号,FTS5 前缀匹配。
|
||
func ftsQuery(text string) string {
|
||
words := splitWords(text)
|
||
if len(words) == 0 {
|
||
return `""`
|
||
}
|
||
out := ""
|
||
for i, w := range words {
|
||
if i > 0 {
|
||
out += " "
|
||
}
|
||
out += `"` + w + `"*`
|
||
}
|
||
return out
|
||
}
|
||
|
||
func splitWords(s string) []string {
|
||
out := []string{}
|
||
cur := []rune{}
|
||
for _, r := range s {
|
||
if r == ' ' || r == '\t' || r == '\n' {
|
||
if len(cur) > 0 {
|
||
out = append(out, string(cur))
|
||
cur = nil
|
||
}
|
||
continue
|
||
}
|
||
cur = append(cur, r)
|
||
}
|
||
if len(cur) > 0 {
|
||
out = append(out, string(cur))
|
||
}
|
||
return out
|
||
}
|
||
|
||
func containsStr(list []string, s string) bool {
|
||
for _, x := range list {
|
||
if x == s {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|