Files
2026-07-10 00:55:13 +07:00

154 lines
4.0 KiB
Go

//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"sort"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// Search performs LIKE-based text search over episodic summaries.
// Vector search is not available in the SQLite edition.
// Scoring: 1.0 base, +0.2 if l0_abstract matches, +0.1 if key_topics matches.
func (s *SQLiteEpisodicStore) Search(ctx context.Context, query string, agentID, userID string, opts store.EpisodicSearchOptions) ([]store.EpisodicSearchResult, error) {
// F10: cap query to prevent degenerate LIKE patterns
if len(query) > 500 {
query = query[:500]
}
maxResults := opts.MaxResults
if maxResults <= 0 {
maxResults = 10
}
tenantID := tenantIDForInsert(ctx)
query = strings.TrimSpace(query)
listOnly := query == "" || query == "*"
q := `
SELECT id, COALESCE(NULLIF(l0_abstract, ''), substr(summary, 1, 500)) AS l0_abstract, key_topics, created_at, expires_at, session_key
FROM episodic_summaries
WHERE agent_id = ? AND tenant_id = ?`
args := []any{agentID, tenantID.String()}
if !listOnly {
pattern := "%" + escapeLike(query) + "%"
q += " AND (summary LIKE ? ESCAPE '\\' OR key_topics LIKE ? ESCAPE '\\')"
args = append(args, pattern, pattern)
}
if store.IsSharedMemory(ctx) {
// Shared memory searches all user scopes for the agent.
} else if userID != "" {
q += " AND (user_id = ? OR user_id = '')"
args = append(args, userID)
} else {
q += " AND user_id = ''"
}
if opts.CreatedAfter != nil {
q += " AND created_at >= ?"
args = append(args, opts.CreatedAfter.UTC().Format(time.RFC3339Nano))
}
if opts.CreatedBefore != nil {
q += " AND created_at < ?"
args = append(args, opts.CreatedBefore.UTC().Format(time.RFC3339Nano))
}
if !opts.IncludeExpired {
q += " AND (expires_at IS NULL OR expires_at > ?)"
args = append(args, time.Now().UTC().Format(time.RFC3339Nano))
}
q += " ORDER BY created_at DESC LIMIT ?"
args = append(args, maxResults*3)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
type rawRow struct {
id string
l0Abstract string
keyTopics string
createdAt sqliteTime
expiresAt nullSqliteTime
sessionKey string
}
var raws []rawRow
for rows.Next() {
var r rawRow
if err := rows.Scan(&r.id, &r.l0Abstract, &r.keyTopics, &r.createdAt, &r.expiresAt, &r.sessionKey); err != nil {
continue
}
raws = append(raws, r)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Post-query scoring
lowerQuery := strings.ToLower(query)
type scored struct {
raw rawRow
score float64
}
scoredRows := make([]scored, 0, len(raws))
for _, r := range raws {
sc := 1.0
if !listOnly {
if strings.Contains(strings.ToLower(r.l0Abstract), lowerQuery) {
sc += 0.2
}
if strings.Contains(strings.ToLower(r.keyTopics), lowerQuery) {
sc += 0.1
}
}
scoredRows = append(scoredRows, scored{raw: r, score: sc})
}
// Sort by score DESC, then created_at DESC
sort.SliceStable(scoredRows, func(i, j int) bool {
if scoredRows[i].score != scoredRows[j].score {
return scoredRows[i].score > scoredRows[j].score
}
return scoredRows[i].raw.createdAt.Time.After(scoredRows[j].raw.createdAt.Time)
})
var results []store.EpisodicSearchResult
for _, sr := range scoredRows {
if opts.MinScore > 0 && sr.score < opts.MinScore {
continue
}
var expiresAt *time.Time
if sr.raw.expiresAt.Valid {
t := sr.raw.expiresAt.Time
expiresAt = &t
}
results = append(results, store.EpisodicSearchResult{
EpisodicID: sr.raw.id,
L0Abstract: sr.raw.l0Abstract,
KeyTopics: searchKeyTopics(sr.raw.keyTopics),
Score: sr.score,
CreatedAt: sr.raw.createdAt.Time,
ExpiresAt: expiresAt,
SessionKey: sr.raw.sessionKey,
})
if len(results) >= maxResults {
break
}
}
return results, nil
}
func searchKeyTopics(raw string) []string {
var topics []string
scanJSONStringArray([]byte(raw), &topics)
return topics
}
// Ensure SQLiteEpisodicStore implements store.EpisodicStore.
var _ store.EpisodicStore = (*SQLiteEpisodicStore)(nil)