返回 DeepSeek-Reasonix
search.go
1 package historycatalog
2
3 import (
4 "context"
5 "path/filepath"
6 "strings"
7
8 "reasonix/internal/retrieval"
9 )
10
11 func (c *Catalog) Search(ctx context.Context, req SearchRequest) (SearchResult, error) {
12 out := SearchResult{Items: []Candidate{}, Revision: c.revision.Load(), Partial: c.Status().Pending > 0}
13 err := c.searchCandidates(ctx, req, false, func(item Candidate) error {
14 out.Items = append(out.Items, item)
15 return nil
16 })
17 return out, err
18 }
19
20 func (c *Catalog) searchCandidates(ctx context.Context, req SearchRequest, captureAll bool, visit func(Candidate) error) error {
21 query, args, err := historySearchQuery(req, captureAll)
22 if err != nil {
23 return err
24 }
25 rows, err := c.searchDB(ctx).QueryContext(ctx, query, args...)
26 if err != nil {
27 return err
28 }
29 defer rows.Close()
30 for rows.Next() {
31 var item Candidate
32 if err := rows.Scan(&item.RowID, &item.SessionPath, &item.Root, &item.Source, &item.Scope, &item.WorkspaceRoot, &item.ContentDigest,
33 &item.MessageIndex, &item.PartIndex, &item.Role, &item.Kind, &item.ToolName, &item.Rank,
34 &item.SessionTitle, &item.TopicTitle, &item.LastActivityAt); err != nil {
35 return err
36 }
37 if !catalogPathWithin(item.SessionPath, item.Root) {
38 continue
39 }
40 if item.Rank < 0 {
41 item.Score = -item.Rank
42 } else {
43 item.Score = 1 / (1 + item.Rank)
44 }
45 if err := visit(item); err != nil {
46 return err
47 }
48 }
49 return rows.Err()
50 }
51
52 func historySearchQuery(req SearchRequest, captureAll bool) (string, []any, error) {
53 terms, err := retrieval.QueryTerms(req.Query)
54 if err != nil {
55 return "", nil, err
56 }
57 limit := req.Limit
58 if limit <= 0 {
59 limit = DefaultLimit
60 }
61 if limit > MaxLimit {
62 limit = MaxLimit
63 }
64 if captureAll {
65 limit = 2147483647
66 }
67 match := make([]string, 0, len(terms))
68 for _, term := range terms {
69 match = append(match, `"`+strings.ReplaceAll(term, `"`, `""`)+`"`)
70 }
71 where := []string{`history_fts MATCH ?`, `s.health='ok'`, `s.missing_since=0`}
72 args := []any{strings.Join(match, " OR ")}
73 appendHistorySearchFilters(req, &where, &args)
74 base := `SELECT d.id AS id,d.source_path AS source_path,s.root AS root,s.source AS source,s.scope AS scope,
75 s.workspace_root AS workspace_root,s.content_digest AS content_digest,d.message_index AS message_index,
76 d.part_index AS part_index,d.role AS role,d.kind AS kind,d.tool_name AS tool_name,bm25(history_fts) AS rank,
77 s.custom_title AS custom_title,s.topic_title AS topic_title,s.last_activity_at AS last_activity_at
78 FROM history_fts JOIN history_documents d ON d.id=history_fts.rowid JOIN history_sources s ON s.path=d.source_path
79 WHERE ` + strings.Join(where, ` AND `)
80 query := base + ` ORDER BY bm25(history_fts),d.source_path,d.message_index,d.part_index,d.id LIMIT ?`
81 if after := req.After; after != nil {
82 query = `WITH ranked AS MATERIALIZED (` + base + `)
83 SELECT * FROM ranked WHERE rank>? OR (rank=? AND source_path>?) OR
84 (rank=? AND source_path=? AND message_index>?) OR
85 (rank=? AND source_path=? AND message_index=? AND part_index>?) OR
86 (rank=? AND source_path=? AND message_index=? AND part_index=? AND id>?)
87 ORDER BY rank,source_path,message_index,part_index,id LIMIT ?`
88 args = append(args, after.Rank, after.Rank, after.SessionPath,
89 after.Rank, after.SessionPath, after.MessageIndex,
90 after.Rank, after.SessionPath, after.MessageIndex, after.PartIndex,
91 after.Rank, after.SessionPath, after.MessageIndex, after.PartIndex, after.RowID)
92 }
93 return query, append(args, limit), nil
94 }
95
96 func appendHistorySearchFilters(req SearchRequest, where *[]string, args *[]any) {
97 if req.Scope == "project" {
98 *where = append(*where, `s.scope='project'`, `s.workspace_root=?`)
99 *args = append(*args, strings.TrimSpace(req.WorkspaceRoot))
100 }
101 if path := strings.TrimSpace(req.SessionPath); path != "" {
102 *where = append(*where, `d.source_path=?`)
103 *args = append(*args, filepath.Clean(path))
104 }
105 if len(req.Kinds) > 0 {
106 placeholders := make([]string, len(req.Kinds))
107 for i, kind := range req.Kinds {
108 placeholders[i] = "?"
109 *args = append(*args, kind)
110 }
111 *where = append(*where, `d.kind IN (`+strings.Join(placeholders, ",")+`)`)
112 }
113 if tool := strings.TrimSpace(req.ToolName); tool != "" {
114 *where = append(*where, `d.tool_name=?`)
115 *args = append(*args, tool)
116 }
117 if len(req.Roots) > 0 {
118 placeholders := make([]string, 0, len(req.Roots))
119 for _, root := range req.Roots {
120 if strings.TrimSpace(root) == "" {
121 continue
122 }
123 placeholders = append(placeholders, "?")
124 *args = append(*args, filepath.Clean(root))
125 }
126 if len(placeholders) > 0 {
127 *where = append(*where, `s.root IN (`+strings.Join(placeholders, ",")+`)`)
128 }
129 }
130 }
131
132 func catalogPathWithin(path, root string) bool {
133 absPath, err := filepath.Abs(filepath.Clean(strings.TrimSpace(path)))
134 if err != nil {
135 return false
136 }
137 absRoot, err := filepath.Abs(filepath.Clean(strings.TrimSpace(root)))
138 if err != nil {
139 return false
140 }
141 rel, err := filepath.Rel(absRoot, absPath)
142 return err == nil && (rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))))
143 }
144
144 lines GO