Files
Agent-System-Backend/internal/store/content.go
T

960 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"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
}
// CreateArtifactAccountChecked stages content under a fresh, unreferenced key.
// The artifact only becomes visible when its row commits with the actor's
// effective-account check. A rejected transaction cannot expose staged bytes.
func (s *Store) CreateArtifactAccountChecked(actor string, a *core.Artifact, content []byte, allowImplicitDefault bool) error {
if a == nil || a.Owner != actor {
return ErrNotFound
}
if actor == "" {
return ErrAccountForbidden
}
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(newID("blob"), content)
if err != nil {
return err
}
var callbackReached bool
var callbackErr error
err = s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
callbackReached = true
_, callbackErr = tx.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, actor, a.Name, a.Kind, a.MIME, a.Size, a.SourceRun, ref, nowISO(), nowISO())
return callbackErr
})
if err != nil && (!callbackReached || callbackErr != nil) {
_ = s.blobs.Delete(ref)
}
return err
}
func (s *Store) UpdateArtifactContent(owner, id string, content []byte) error {
// Legacy callers retain the old signature, but share immutable replacement:
// account-checked creations may already point at a key other than the ID.
ref, err := s.blobs.Put(newID("blob"), content)
if err != nil {
return err
}
tx, err := s.db.Begin()
if err != nil {
_ = s.blobs.Delete(ref)
return err
}
defer tx.Rollback()
oldRef, err := s.switchArtifactContentTx(tx, owner, id, ref, len(content))
if err != nil {
_ = tx.Rollback()
_ = s.blobs.Delete(ref)
return err
}
if err := tx.Commit(); err != nil {
// The commit may have reached the DB. Preserve the staged object.
return err
}
// The DB pointer is authoritative. Blob deletion is external and may fail
// after a successful commit; callers can retry cleanup separately.
if oldRef == "" {
return nil
}
return s.blobs.Delete(oldRef)
}
// UpdateArtifactContentAccountChecked swaps only the visible DB reference in
// the account-guarded transaction. Never overwrite the old referenced object.
func (s *Store) UpdateArtifactContentAccountChecked(actor, id string, content []byte, allowImplicitDefault bool) error {
if actor == "" {
return ErrAccountForbidden
}
ref, err := s.blobs.Put(newID("blob"), content)
if err != nil {
return err
}
var oldRef string
var callbackReached bool
var callbackErr error
err = s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
callbackReached = true
oldRef, callbackErr = s.switchArtifactContentTx(tx, actor, id, ref, len(content))
return callbackErr
})
if err != nil && (!callbackReached || callbackErr != nil) {
_ = s.blobs.Delete(ref)
return err
}
if err != nil {
return err
}
// The old object can be removed only after the new reference commits.
if oldRef == "" {
return nil
}
return s.blobs.Delete(oldRef)
}
func (s *Store) switchArtifactContentTx(tx *sql.Tx, owner, id, newRef string, size int) (string, error) {
// Serialize overwrites with task projection source-version witnesses.
if err := s.dialect.lockScope(tx, owner, "artifact:"+id); err != nil {
return "", err
}
query := s.q(`SELECT content_path FROM artifacts WHERE id=? AND owner=?`)
if s.Dialect() == "postgres" {
query += ` FOR UPDATE`
}
var oldRef string
if err := tx.QueryRow(query, id, owner).Scan(&oldRef); errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
} else if err != nil {
return "", err
}
res, err := tx.Exec(s.q(`UPDATE artifacts SET content_path=?, size=?, updated_at=? WHERE id=? AND owner=?`), newRef, size, nowISO(), id, owner)
if err != nil {
return "", err
}
if n, err := res.RowsAffected(); err != nil {
return "", err
} else if n != 1 {
return "", ErrNotFound
}
return oldRef, 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 {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err = s.dialect.lockScope(tx, owner, "artifact:"+id); err != nil {
return err
}
var path string
err = tx.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 = tx.Exec(s.q(`DELETE FROM artifacts WHERE id=? AND owner=?`), id, owner); err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
// Preserve the existing lexical/vector cleanup after the authoritative delete.
_ = s.DeleteKnowledgeByRef(owner, id)
_ = s.DeleteKnowledgeVectors(context.Background(), owner, id)
return s.blobs.Delete(path)
}
// DeleteArtifactAccountChecked removes Backend-owned rows in one guarded
// transaction. The blob is unlinked only after commit; an unlink failure may
// leave an orphan, but cannot restore a deleted DB row or authorize a late write.
func (s *Store) DeleteArtifactAccountChecked(actor, id string, allowImplicitDefault bool) error {
var ref string
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
if err := s.dialect.lockScope(tx, actor, "artifact:"+id); err != nil {
return err
}
query := s.q(`SELECT content_path FROM artifacts WHERE id=? AND owner=?`)
if s.Dialect() == "postgres" {
query += ` FOR UPDATE`
}
if err := tx.QueryRow(query, id, actor).Scan(&ref); errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
} else if err != nil {
return err
}
if _, err := tx.Exec(s.q(`DELETE FROM knowledge_vectors WHERE ref=? AND owner=?`), id, actor); err != nil {
return err
}
if _, err := tx.Exec(s.q(`DELETE FROM knowledge WHERE ref=? AND owner=?`), id, actor); err != nil {
return err
}
res, err := tx.Exec(s.q(`DELETE FROM artifacts WHERE id=? AND owner=?`), id, actor)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil {
return err
} else if n != 1 {
return ErrNotFound
}
return nil
})
if err != nil {
return err
}
return s.blobs.Delete(ref)
}
// ---- 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)
}
func (s *Store) HITLResolution(owner, id string) (core.HITLResolvedPayload, error) {
var payload string
err := s.db.QueryRow(s.q(`SELECT resolution FROM hitl_cards WHERE id=? AND owner=? AND resolved=1`), id, owner).Scan(&payload)
if errors.Is(err, sql.ErrNoRows) {
return core.HITLResolvedPayload{}, ErrNotFound
}
if err != nil {
return core.HITLResolvedPayload{}, err
}
return unmarshalJSON[core.HITLResolvedPayload](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()
if err := s.addKnowledgeTx(tx, docs); err != nil {
return err
}
return tx.Commit()
}
// AddKnowledgeAccountChecked keeps the whole batch behind one final account
// check and transaction. A caller cannot publish rows under another owner.
func (s *Store) AddKnowledgeAccountChecked(actor string, allowImplicitDefault bool, docs ...core.KnowledgeDoc) error {
if len(docs) == 0 {
return nil
}
for _, doc := range docs {
if doc.Owner != core.ID(actor) {
return ErrNotFound
}
}
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
return s.addKnowledgeTx(tx, docs)
})
}
// ReplaceKnowledgeAccountChecked atomically removes the affected publication
// refs and inserts all replacement chunks. A failed insert rolls the removals
// back; embeddings and their later vectors are separate guarded operations.
// scoped=true limits each ref to the exact project and plugin namespace.
func (s *Store) ReplaceKnowledgeAccountChecked(ctx context.Context, actor, pluginID string, scoped, allowImplicitDefault bool, docs ...core.KnowledgeDoc) error {
if len(docs) == 0 {
return nil
}
if scoped && pluginID == "" {
return ErrNotFound
}
prefix := "plugin:" + pluginID
type publicationRef struct{ projectID, ref string }
refs := map[publicationRef]bool{}
for _, doc := range docs {
if doc.Owner != core.ID(actor) {
return ErrNotFound
}
if scoped && doc.Source != prefix && !strings.HasPrefix(doc.Source, prefix+":") {
return ErrNotFound
}
if doc.Ref != "" {
refs[publicationRef{projectID: string(doc.ProjectID), ref: doc.Ref}] = true
}
}
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
if err := ctx.Err(); err != nil {
return err
}
for ref := range refs {
pred := `owner=? AND ref=?`
args := []any{actor, ref.ref}
if scoped {
pred += ` AND project_id=? AND (source=? OR substr(source,1,length(?))=?)`
args = append(args, ref.projectID, prefix, prefix+":", prefix+":")
} else if ref.projectID != "" {
pred += ` AND project_id=?`
args = append(args, ref.projectID)
}
if _, err := s.deleteKnowledgeAndVectorsTx(ctx, tx, pred, args...); err != nil {
return err
}
}
if err := ctx.Err(); err != nil {
return err
}
return s.addKnowledgeTx(tx, docs)
})
}
func (s *Store) addKnowledgeTx(tx *sql.Tx, docs []core.KnowledgeDoc) error {
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 nil
}
// 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
}
// DeleteKnowledgeByRefAccountChecked removes the index rows and their vectors
// in the same account-guarded transaction.
func (s *Store) DeleteKnowledgeByRefAccountChecked(ctx context.Context, actor, ref string, allowImplicitDefault bool) error {
_, err := s.deleteKnowledgeAccountChecked(ctx, actor, allowImplicitDefault, `owner=? AND ref=?`, actor, ref)
return err
}
// DeleteKnowledgeBySourceAccountChecked treats the prefix literally and only
// matches its namespace (prefix itself or prefix followed by ':'). SQL LIKE
// would treat plugin IDs containing '_' or '%' as wildcards.
func (s *Store) DeleteKnowledgeBySourceAccountChecked(ctx context.Context, actor, sourcePrefix string, allowImplicitDefault bool) (int64, error) {
if sourcePrefix == "" {
return 0, ErrNotFound
}
childPrefix := sourcePrefix + ":"
return s.deleteKnowledgeAccountChecked(ctx, actor, allowImplicitDefault,
`owner=? AND (source=? OR substr(source,1,length(?))=?)`, actor, sourcePrefix, childPrefix, childPrefix)
}
func (s *Store) deleteKnowledgeAccountChecked(ctx context.Context, actor string, allowImplicitDefault bool, pred string, args ...any) (int64, error) {
var deleted int64
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
var err error
deleted, err = s.deleteKnowledgeAndVectorsTx(ctx, tx, pred, args...)
return err
})
return deleted, 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 preserves the ordinary legacy replacement scope.
// Registered lifecycle publishers use DeletePluginProjectKnowledgeByRef instead.
func (s *Store) DeleteProjectKnowledgeByRef(ctx context.Context, owner, projectID, ref string) error {
args := []any{owner, ref}
pred := "owner=? AND ref=?"
if projectID != "" {
pred += " AND project_id=?"
args = append(args, projectID)
}
return s.deleteKnowledgeAndVectors(ctx, pred, args...)
}
// DeleteProjectKnowledgeByRefAccountChecked retains legacy project scoping:
// empty projectID means all of this actor's projects for the given ref.
func (s *Store) DeleteProjectKnowledgeByRefAccountChecked(ctx context.Context, actor, projectID, ref string, allowImplicitDefault bool) error {
args := []any{actor, ref}
pred := `owner=? AND ref=?`
if projectID != "" {
pred += ` AND project_id=?`
args = append(args, projectID)
}
_, err := s.deleteKnowledgeAccountChecked(ctx, actor, allowImplicitDefault, pred, args...)
return err
}
// DeletePluginProjectKnowledgeByRef replaces only this actor/plugin publication
// scope. Empty project is an exact unscoped domain, never a wildcard. The source
// prefix is compared literally, including any percent/underscore in plugin IDs.
func (s *Store) DeletePluginProjectKnowledgeByRef(ctx context.Context, owner, projectID, ref, pluginID string) error {
if owner == "" || pluginID == "" {
return errors.New("store: publication owner and plugin required")
}
prefix := "plugin:" + pluginID
pred := `owner=? AND project_id=? AND ref=? AND (source=? OR substr(source,1,length(?))=?)`
return s.deleteKnowledgeAndVectors(ctx, pred, owner, projectID, ref, prefix, prefix+":", prefix+":")
}
// DeletePluginProjectKnowledgeByRefAccountChecked retains the exact project,
// ref and plugin namespace restriction, including literal '%' and '_' IDs.
func (s *Store) DeletePluginProjectKnowledgeByRefAccountChecked(ctx context.Context, actor, projectID, ref, pluginID string, allowImplicitDefault bool) error {
if pluginID == "" {
return ErrNotFound
}
prefix := "plugin:" + pluginID
pred := `owner=? AND project_id=? AND ref=? AND (source=? OR substr(source,1,length(?))=?)`
_, err := s.deleteKnowledgeAccountChecked(ctx, actor, allowImplicitDefault, pred,
actor, projectID, ref, prefix, prefix+":", prefix+":")
return err
}
func (s *Store) deleteKnowledgeAndVectors(ctx context.Context, pred string, args ...any) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := s.deleteKnowledgeAndVectorsTx(ctx, tx, pred, args...); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) deleteKnowledgeAndVectorsTx(ctx context.Context, tx *sql.Tx, pred string, args ...any) (int64, error) {
if err := ctx.Err(); err != nil {
return 0, err
}
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 0, err
}
res, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge WHERE "+pred), args...)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
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
}