Files
Cyrene_v2/internal/runtime/memory/projectindex/search.go
T

109 lines
3.4 KiB
Go

package projectindex
import (
"context"
"strings"
)
// contextRadius is the number of lines shown on each side of a matched line in
// LineMatch.Context.
const contextRadius = 2
// LineMatch is a single matched line inside a chapter's content plus its
// surrounding context. LineNumber is 1-based within the chapter's Content.
type LineMatch struct {
LineNumber int
Text string
Context string
}
// ChapterHit is a chapter whose content matched a keyword, with the located
// lines (and their context) that matched.
type ChapterHit struct {
Chapter *Chapter
Matches []LineMatch
}
// SearchResult is the hierarchical result of a Search. Project carries the
// matched project index entry + description (layer 1/2), set only when the
// keyword hits the project title or desc; Chapters carries the matched chapters
// (layer 3) with located lines. When nothing matches, Project is nil and
// Chapters is empty, with no error.
type SearchResult struct {
Project *Project
Chapters []ChapterHit
}
// Search looks up keyword within a single project, walking the hierarchy
// 项目索引 → 项目描述 → 章节内容. It first loads the project (ErrProjectNotFound
// when it does not exist), sets SearchResult.Project when the keyword hits the
// project title or description, then scans every chapter for content hits,
// producing located lines plus their context. A blank keyword returns
// ErrMissingKeyword; a keyword that hits nothing returns a non-nil SearchResult
// with Project and Chapters unset.
func (s *Store) Search(ctx context.Context, projectID, keyword string) (*SearchResult, error) {
kw := strings.ToLower(strings.TrimSpace(keyword))
if kw == "" {
return nil, ErrMissingKeyword
}
proj, err := s.GetProject(ctx, projectID)
if err != nil {
return nil, err
}
result := &SearchResult{}
if containsFold(proj.Title, kw) || containsFold(proj.Desc, kw) {
result.Project = proj
}
chapters, err := s.ListChapters(ctx, projectID)
if err != nil {
return nil, err
}
for i := range chapters {
matches := findLineMatches(chapters[i].Content, kw)
if len(matches) == 0 {
continue
}
ch := chapters[i]
result.Chapters = append(result.Chapters, ChapterHit{Chapter: &ch, Matches: matches})
}
return result, nil
}
// containsFold reports whether s (case-insensitively) contains sub, which must
// already be lowercased by the caller.
func containsFold(s, sub string) bool {
return strings.Contains(strings.ToLower(s), sub)
}
// findLineMatches returns one LineMatch per content line that (case-insensitively)
// contains keyword. Context is the surrounding contextRadius lines on each side,
// joined by newlines and including the matched line.
func findLineMatches(content, keyword string) []LineMatch {
lines := strings.Split(content, "\n")
matches := make([]LineMatch, 0)
for i, line := range lines {
if strings.Contains(strings.ToLower(line), keyword) {
matches = append(matches, LineMatch{
LineNumber: i + 1,
Text: line,
Context: buildContext(lines, i),
})
}
}
return matches
}
// buildContext returns lines[start..end] around idx (inclusive) joined by \n,
// clamped to the slice bounds so the context never overflows.
func buildContext(lines []string, idx int) string {
start := idx - contextRadius
if start < 0 {
start = 0
}
end := idx + contextRadius
if end >= len(lines) {
end = len(lines) - 1
}
return strings.Join(lines[start:end+1], "\n")
}