mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-06-05 23:03:55 +00:00
328 lines
9.2 KiB
Go
328 lines
9.2 KiB
Go
// Package search provides unified search capabilities for claude-mnemonic.
|
|
package search
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
|
|
"github.com/lukaszraczylo/claude-mnemonic/internal/db/sqlite"
|
|
"github.com/lukaszraczylo/claude-mnemonic/internal/vector/chroma"
|
|
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
|
)
|
|
|
|
// Manager provides unified search across SQLite and ChromaDB.
|
|
type Manager struct {
|
|
observationStore *sqlite.ObservationStore
|
|
summaryStore *sqlite.SummaryStore
|
|
promptStore *sqlite.PromptStore
|
|
chromaClient *chroma.Client
|
|
}
|
|
|
|
// NewManager creates a new search manager.
|
|
func NewManager(
|
|
observationStore *sqlite.ObservationStore,
|
|
summaryStore *sqlite.SummaryStore,
|
|
promptStore *sqlite.PromptStore,
|
|
chromaClient *chroma.Client,
|
|
) *Manager {
|
|
return &Manager{
|
|
observationStore: observationStore,
|
|
summaryStore: summaryStore,
|
|
promptStore: promptStore,
|
|
chromaClient: chromaClient,
|
|
}
|
|
}
|
|
|
|
// SearchParams contains parameters for unified search.
|
|
type SearchParams struct {
|
|
Query string
|
|
Type string // "observations", "sessions", "prompts", or empty for all
|
|
Project string
|
|
ObsType string // Observation type filter
|
|
Concepts string
|
|
Files string
|
|
DateStart int64
|
|
DateEnd int64
|
|
OrderBy string // "relevance", "date_desc", "date_asc"
|
|
Limit int
|
|
Offset int
|
|
Format string // "index" or "full"
|
|
Scope string // "project", "global", or empty for project+global
|
|
IncludeGlobal bool // If true, include global observations along with project-scoped
|
|
}
|
|
|
|
// SearchResult represents a unified search result.
|
|
type SearchResult struct {
|
|
Type string `json:"type"` // "observation", "session", "prompt"
|
|
ID int64 `json:"id"`
|
|
Title string `json:"title,omitempty"`
|
|
Content string `json:"content,omitempty"`
|
|
Project string `json:"project"`
|
|
Scope string `json:"scope,omitempty"` // "project" or "global"
|
|
CreatedAt int64 `json:"created_at_epoch"`
|
|
Score float64 `json:"score,omitempty"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
}
|
|
|
|
// UnifiedSearchResult contains the combined search results.
|
|
type UnifiedSearchResult struct {
|
|
Results []SearchResult `json:"results"`
|
|
TotalCount int `json:"total_count"`
|
|
Query string `json:"query,omitempty"`
|
|
}
|
|
|
|
// UnifiedSearch performs a unified search across all document types.
|
|
func (m *Manager) UnifiedSearch(ctx context.Context, params SearchParams) (*UnifiedSearchResult, error) {
|
|
if params.Limit <= 0 {
|
|
params.Limit = 20
|
|
}
|
|
if params.Limit > 100 {
|
|
params.Limit = 100
|
|
}
|
|
if params.OrderBy == "" {
|
|
params.OrderBy = "date_desc"
|
|
}
|
|
|
|
// If query is provided and Chroma is available, use vector search
|
|
if params.Query != "" && m.chromaClient != nil {
|
|
return m.vectorSearch(ctx, params)
|
|
}
|
|
|
|
// Otherwise fall back to structured filter search
|
|
return m.filterSearch(ctx, params)
|
|
}
|
|
|
|
// vectorSearch performs semantic search via ChromaDB.
|
|
func (m *Manager) vectorSearch(ctx context.Context, params SearchParams) (*UnifiedSearchResult, error) {
|
|
// Build where filter
|
|
where := make(map[string]interface{})
|
|
if params.Project != "" {
|
|
where["project"] = params.Project
|
|
}
|
|
if params.Type == "observations" {
|
|
where["doc_type"] = "observation"
|
|
} else if params.Type == "sessions" {
|
|
where["doc_type"] = "session_summary"
|
|
} else if params.Type == "prompts" {
|
|
where["doc_type"] = "user_prompt"
|
|
}
|
|
|
|
// Query ChromaDB
|
|
chromaResults, err := m.chromaClient.Query(ctx, params.Query, params.Limit*2, where)
|
|
if err != nil {
|
|
// Fall back to filter search on error
|
|
return m.filterSearch(ctx, params)
|
|
}
|
|
|
|
// Collect unique IDs by type
|
|
obsIDs := make([]int64, 0)
|
|
summaryIDs := make([]int64, 0)
|
|
promptIDs := make([]int64, 0)
|
|
seenObs := make(map[int64]bool)
|
|
seenSummary := make(map[int64]bool)
|
|
seenPrompt := make(map[int64]bool)
|
|
|
|
for _, result := range chromaResults {
|
|
sqliteID, ok := result.Metadata["sqlite_id"].(float64)
|
|
if !ok {
|
|
continue
|
|
}
|
|
id := int64(sqliteID)
|
|
|
|
docType, _ := result.Metadata["doc_type"].(string)
|
|
switch docType {
|
|
case "observation":
|
|
if !seenObs[id] {
|
|
seenObs[id] = true
|
|
obsIDs = append(obsIDs, id)
|
|
}
|
|
case "session_summary":
|
|
if !seenSummary[id] {
|
|
seenSummary[id] = true
|
|
summaryIDs = append(summaryIDs, id)
|
|
}
|
|
case "user_prompt":
|
|
if !seenPrompt[id] {
|
|
seenPrompt[id] = true
|
|
promptIDs = append(promptIDs, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fetch full records from SQLite
|
|
var results []SearchResult
|
|
|
|
if len(obsIDs) > 0 && (params.Type == "" || params.Type == "observations") {
|
|
obs, err := m.observationStore.GetObservationsByIDs(ctx, obsIDs, params.OrderBy, 0)
|
|
if err == nil {
|
|
for _, o := range obs {
|
|
results = append(results, m.observationToResult(o, params.Format))
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(summaryIDs) > 0 && (params.Type == "" || params.Type == "sessions") {
|
|
summaries, err := m.summaryStore.GetSummariesByIDs(ctx, summaryIDs, params.OrderBy, 0)
|
|
if err == nil {
|
|
for _, s := range summaries {
|
|
results = append(results, m.summaryToResult(s, params.Format))
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(promptIDs) > 0 && (params.Type == "" || params.Type == "prompts") {
|
|
prompts, err := m.promptStore.GetPromptsByIDs(ctx, promptIDs, params.OrderBy, 0)
|
|
if err == nil {
|
|
for _, p := range prompts {
|
|
results = append(results, m.promptToResult(p, params.Format))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply limit
|
|
if len(results) > params.Limit {
|
|
results = results[:params.Limit]
|
|
}
|
|
|
|
return &UnifiedSearchResult{
|
|
Results: results,
|
|
TotalCount: len(results),
|
|
Query: params.Query,
|
|
}, nil
|
|
}
|
|
|
|
// filterSearch performs structured filter search via SQLite.
|
|
func (m *Manager) filterSearch(ctx context.Context, params SearchParams) (*UnifiedSearchResult, error) {
|
|
var results []SearchResult
|
|
|
|
// Search observations
|
|
if params.Type == "" || params.Type == "observations" {
|
|
obs, err := m.observationStore.GetRecentObservations(ctx, params.Project, params.Limit)
|
|
if err == nil {
|
|
for _, o := range obs {
|
|
results = append(results, m.observationToResult(o, params.Format))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Search summaries
|
|
if params.Type == "" || params.Type == "sessions" {
|
|
summaries, err := m.summaryStore.GetRecentSummaries(ctx, params.Project, params.Limit)
|
|
if err == nil {
|
|
for _, s := range summaries {
|
|
results = append(results, m.summaryToResult(s, params.Format))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply limit
|
|
if len(results) > params.Limit {
|
|
results = results[:params.Limit]
|
|
}
|
|
|
|
return &UnifiedSearchResult{
|
|
Results: results,
|
|
TotalCount: len(results),
|
|
}, nil
|
|
}
|
|
|
|
// Decisions performs a semantic search optimized for finding decisions.
|
|
func (m *Manager) Decisions(ctx context.Context, params SearchParams) (*UnifiedSearchResult, error) {
|
|
// Boost query with decision-related keywords
|
|
if params.Query != "" {
|
|
params.Query = params.Query + " decision chose architecture"
|
|
}
|
|
params.Type = "observations"
|
|
return m.UnifiedSearch(ctx, params)
|
|
}
|
|
|
|
// Changes performs a semantic search optimized for finding code changes.
|
|
func (m *Manager) Changes(ctx context.Context, params SearchParams) (*UnifiedSearchResult, error) {
|
|
// Boost query with change-related keywords
|
|
if params.Query != "" {
|
|
params.Query = params.Query + " changed modified refactored"
|
|
}
|
|
params.Type = "observations"
|
|
return m.UnifiedSearch(ctx, params)
|
|
}
|
|
|
|
// HowItWorks performs a semantic search optimized for understanding architecture.
|
|
func (m *Manager) HowItWorks(ctx context.Context, params SearchParams) (*UnifiedSearchResult, error) {
|
|
// Boost query with architecture-related keywords
|
|
if params.Query != "" {
|
|
params.Query = params.Query + " architecture design pattern implements"
|
|
}
|
|
params.Type = "observations"
|
|
return m.UnifiedSearch(ctx, params)
|
|
}
|
|
|
|
// Helper methods
|
|
|
|
func (m *Manager) observationToResult(obs *models.Observation, format string) SearchResult {
|
|
result := SearchResult{
|
|
Type: "observation",
|
|
ID: obs.ID,
|
|
Project: obs.Project,
|
|
Scope: string(obs.Scope),
|
|
CreatedAt: obs.CreatedAtEpoch,
|
|
Metadata: map[string]interface{}{
|
|
"obs_type": string(obs.Type),
|
|
"scope": string(obs.Scope),
|
|
},
|
|
}
|
|
|
|
if obs.Title.Valid {
|
|
result.Title = obs.Title.String
|
|
}
|
|
|
|
if format == "full" && obs.Narrative.Valid {
|
|
result.Content = obs.Narrative.String
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func (m *Manager) summaryToResult(summary *models.SessionSummary, format string) SearchResult {
|
|
result := SearchResult{
|
|
Type: "session",
|
|
ID: summary.ID,
|
|
Project: summary.Project,
|
|
CreatedAt: summary.CreatedAtEpoch,
|
|
}
|
|
|
|
if summary.Request.Valid {
|
|
result.Title = truncate(summary.Request.String, 100)
|
|
}
|
|
|
|
if format == "full" && summary.Learned.Valid {
|
|
result.Content = summary.Learned.String
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func (m *Manager) promptToResult(prompt *models.UserPromptWithSession, format string) SearchResult {
|
|
result := SearchResult{
|
|
Type: "prompt",
|
|
ID: prompt.ID,
|
|
Project: prompt.Project,
|
|
CreatedAt: prompt.CreatedAtEpoch,
|
|
}
|
|
|
|
result.Title = truncate(prompt.PromptText, 100)
|
|
|
|
if format == "full" {
|
|
result.Content = prompt.PromptText
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func truncate(s string, maxLen int) string {
|
|
s = strings.TrimSpace(s)
|
|
if len(s) <= maxLen {
|
|
return s
|
|
}
|
|
return s[:maxLen] + "..."
|
|
}
|