Files
Agent-System-Backend/internal/store/content.go
T
weichen 4f72aaf3d8 Recheck resumed project work and isolate publication replacement
Why:
Membership could be revoked while a tool waited for confirmation or a queued
HITL reply waited for consumption. Publication replacement also crossed an
empty project scope or another plugin's source namespace for the same Ref.

Evidence:
SELF replay of all four independent review reproductions failed before the
fix and passed unchanged afterward. Committed regressions cover direct and
queued HITL, yes/session confirmation, direct artifact writes, protected vectors,
exact empty project scopes, literal wildcard IDs and unregistered compatibility.
SELF uncached go test ./..., isolated PostgreSQL Store tests and static build passed.

Impact:
Recheck trusted live owner/project authorization when HITL resumes and at write
boundaries. Registered publishers replace only exact owner/project/ref/plugin
rows and vectors; ordinary unregistered publication semantics are preserved.

Refs:
Dev project-member-access implementation plan r1 Task 1; independent Backend
review findings against b9bea5bee0
2026-10-13 09:04:06 +00:00

655 lines
21 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"
"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 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...)
}
// 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+":")
}
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 := 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
}