mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-10-18 06:09:15 +00:00
Initial commit
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
// 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] + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user