mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-10-20 06:27:50 +00:00
Release dec 2025 (#15)
* Resolves issue #13 - Switched model to bge-small-en-v1.5 - Added lazy re-embedding - Added model version tracking per vector - Added conversion of vectors to the new model * Add lfs support to the workflow. * Implements importance scoring with decay + voting #6 * Resolves issue #5 by marking observations as superseeded and scheduled for deletion * Implement pattern detection #7 * Improve injections and observations accuracy - Session start: Recent observations for project context (recency-based) - User prompt: Semantically relevant observations (similarity-based with threshold) * Added two stage retrieval with bi and cross encoder #8 * Implement query expansion and reformulation #9 * Knowledge graph and relationships ( resolves #4 ) - File Overlap Detection: Detects relationships when observations modify/read the same files - Concept Overlap Detection: Detects relationships based on shared semantic concepts - Type Progression Detection: Infers relationships from natural observation type progressions (e.g., discovery → bugfix = "fixes") - Temporal Proximity Detection: Detects relationships between observations in the same session within 5 minutes - Narrative Mention Detection: Detects explicit relationship language in narratives (e.g., "fixes", "depends on", "supersedes") * Add visualisation of the relations to the dashboard. * fixup! Add visualisation of the relations to the dashboard. * Update documentation with new settings and screenshots.
This commit is contained in:
@@ -0,0 +1,448 @@
|
||||
// Package expansion provides context-aware query expansion for improved search recall.
|
||||
package expansion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/internal/embedding"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// QueryIntent represents the detected intent of a query.
|
||||
type QueryIntent string
|
||||
|
||||
const (
|
||||
// IntentQuestion indicates a question-type query (how, why, what, etc.)
|
||||
IntentQuestion QueryIntent = "question"
|
||||
// IntentError indicates an error/debugging query
|
||||
IntentError QueryIntent = "error"
|
||||
// IntentImplementation indicates an implementation/coding query
|
||||
IntentImplementation QueryIntent = "implementation"
|
||||
// IntentArchitecture indicates an architecture/design query
|
||||
IntentArchitecture QueryIntent = "architecture"
|
||||
// IntentGeneral indicates a general lookup query
|
||||
IntentGeneral QueryIntent = "general"
|
||||
)
|
||||
|
||||
// ExpandedQuery represents a query variant with metadata.
|
||||
type ExpandedQuery struct {
|
||||
Query string `json:"query"`
|
||||
Weight float64 `json:"weight"` // Weight for result merging (0.0-1.0)
|
||||
Source string `json:"source"` // Where this expansion came from
|
||||
Intent QueryIntent `json:"intent"` // Detected intent
|
||||
}
|
||||
|
||||
// Expander provides context-aware query expansion.
|
||||
type Expander struct {
|
||||
embedSvc *embedding.Service
|
||||
vocabulary []VocabEntry // Known vocabulary from observations
|
||||
vocabVectors [][]float32 // Embeddings for vocabulary entries
|
||||
vocabMu sync.RWMutex // Protects vocabulary
|
||||
intentPatterns map[QueryIntent][]*regexp.Regexp
|
||||
}
|
||||
|
||||
// VocabEntry represents a vocabulary term from observations.
|
||||
type VocabEntry struct {
|
||||
Term string // The term itself
|
||||
Weight float64 // How common/important this term is (0.0-1.0)
|
||||
Source string // Where it came from (title, concept, narrative)
|
||||
}
|
||||
|
||||
// Config holds expander configuration.
|
||||
type Config struct {
|
||||
// MaxExpansions limits the number of expanded queries returned
|
||||
MaxExpansions int
|
||||
// MinSimilarity is the minimum similarity score for vocabulary expansion
|
||||
MinSimilarity float64
|
||||
// EnableVocabularyExpansion enables finding related terms from observations
|
||||
EnableVocabularyExpansion bool
|
||||
}
|
||||
|
||||
// DefaultConfig returns sensible default configuration.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
MaxExpansions: 4,
|
||||
MinSimilarity: 0.5,
|
||||
EnableVocabularyExpansion: true,
|
||||
}
|
||||
}
|
||||
|
||||
// NewExpander creates a new query expander.
|
||||
func NewExpander(embedSvc *embedding.Service) *Expander {
|
||||
e := &Expander{
|
||||
embedSvc: embedSvc,
|
||||
intentPatterns: buildIntentPatterns(),
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// buildIntentPatterns creates regex patterns for intent detection.
|
||||
func buildIntentPatterns() map[QueryIntent][]*regexp.Regexp {
|
||||
patterns := make(map[QueryIntent][]*regexp.Regexp)
|
||||
|
||||
// Question patterns
|
||||
patterns[IntentQuestion] = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)^(how|why|what|when|where|which|who)\b`),
|
||||
regexp.MustCompile(`(?i)\?$`),
|
||||
regexp.MustCompile(`(?i)\b(explain|describe|understand)\b`),
|
||||
}
|
||||
|
||||
// Error/debugging patterns
|
||||
patterns[IntentError] = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)\b(error|bug|issue|problem|fail|crash|exception|panic)\b`),
|
||||
regexp.MustCompile(`(?i)\b(fix|debug|troubleshoot|resolve)\b`),
|
||||
regexp.MustCompile(`(?i)\b(doesn't work|not working|broken)\b`),
|
||||
}
|
||||
|
||||
// Implementation patterns
|
||||
patterns[IntentImplementation] = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)\b(implement|add|create|build|write|code)\b`),
|
||||
regexp.MustCompile(`(?i)\b(function|method|handler|endpoint|api)\b`),
|
||||
regexp.MustCompile(`(?i)\b(feature|functionality)\b`),
|
||||
}
|
||||
|
||||
// Architecture patterns
|
||||
patterns[IntentArchitecture] = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)\b(architecture|design|pattern|structure)\b`),
|
||||
regexp.MustCompile(`(?i)\b(component|module|layer|service)\b`),
|
||||
regexp.MustCompile(`(?i)\b(flow|pipeline|workflow)\b`),
|
||||
}
|
||||
|
||||
return patterns
|
||||
}
|
||||
|
||||
// DetectIntent analyzes a query to determine its intent.
|
||||
func (e *Expander) DetectIntent(query string) QueryIntent {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return IntentGeneral
|
||||
}
|
||||
|
||||
// Check patterns in priority order
|
||||
intentOrder := []QueryIntent{IntentError, IntentQuestion, IntentImplementation, IntentArchitecture}
|
||||
|
||||
for _, intent := range intentOrder {
|
||||
patterns := e.intentPatterns[intent]
|
||||
for _, pattern := range patterns {
|
||||
if pattern.MatchString(query) {
|
||||
return intent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return IntentGeneral
|
||||
}
|
||||
|
||||
// Expand generates expanded query variants based on the original query.
|
||||
func (e *Expander) Expand(ctx context.Context, query string, cfg Config) []ExpandedQuery {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
intent := e.DetectIntent(query)
|
||||
expansions := make([]ExpandedQuery, 0, cfg.MaxExpansions)
|
||||
|
||||
// Always include the original query with highest weight
|
||||
expansions = append(expansions, ExpandedQuery{
|
||||
Query: query,
|
||||
Weight: 1.0,
|
||||
Source: "original",
|
||||
Intent: intent,
|
||||
})
|
||||
|
||||
// Generate intent-based expansions
|
||||
intentExpansions := e.expandByIntent(query, intent)
|
||||
expansions = append(expansions, intentExpansions...)
|
||||
|
||||
// Generate vocabulary-based expansions if enabled and we have vocabulary
|
||||
if cfg.EnableVocabularyExpansion && e.embedSvc != nil && len(e.vocabulary) > 0 {
|
||||
vocabExpansions := e.expandByVocabulary(ctx, query, cfg.MinSimilarity)
|
||||
expansions = append(expansions, vocabExpansions...)
|
||||
}
|
||||
|
||||
// Deduplicate and limit
|
||||
expansions = deduplicateExpansions(expansions)
|
||||
if len(expansions) > cfg.MaxExpansions {
|
||||
expansions = expansions[:cfg.MaxExpansions]
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("query", truncate(query, 50)).
|
||||
Str("intent", string(intent)).
|
||||
Int("expansions", len(expansions)).
|
||||
Msg("Query expanded")
|
||||
|
||||
return expansions
|
||||
}
|
||||
|
||||
// expandByIntent generates expansions based on detected query intent.
|
||||
func (e *Expander) expandByIntent(query string, intent QueryIntent) []ExpandedQuery {
|
||||
var expansions []ExpandedQuery
|
||||
|
||||
// Extract key terms from query for context-aware expansion
|
||||
keyTerms := extractKeyTerms(query)
|
||||
|
||||
switch intent {
|
||||
case IntentQuestion:
|
||||
// For questions, create a declarative variant
|
||||
declarative := makeDeclarative(query)
|
||||
if declarative != query {
|
||||
expansions = append(expansions, ExpandedQuery{
|
||||
Query: declarative,
|
||||
Weight: 0.85,
|
||||
Source: "intent:declarative",
|
||||
Intent: intent,
|
||||
})
|
||||
}
|
||||
|
||||
case IntentError:
|
||||
// For errors, expand with solution-oriented terms
|
||||
if len(keyTerms) > 0 {
|
||||
solutionQuery := strings.Join(keyTerms, " ") + " solution fix"
|
||||
expansions = append(expansions, ExpandedQuery{
|
||||
Query: solutionQuery,
|
||||
Weight: 0.8,
|
||||
Source: "intent:solution",
|
||||
Intent: intent,
|
||||
})
|
||||
}
|
||||
|
||||
case IntentImplementation:
|
||||
// For implementation queries, focus on the what/how
|
||||
if len(keyTerms) > 0 {
|
||||
howQuery := "how " + strings.Join(keyTerms, " ")
|
||||
expansions = append(expansions, ExpandedQuery{
|
||||
Query: howQuery,
|
||||
Weight: 0.75,
|
||||
Source: "intent:how",
|
||||
Intent: intent,
|
||||
})
|
||||
}
|
||||
|
||||
case IntentArchitecture:
|
||||
// For architecture queries, expand with design context
|
||||
if len(keyTerms) > 0 {
|
||||
designQuery := strings.Join(keyTerms, " ") + " design structure"
|
||||
expansions = append(expansions, ExpandedQuery{
|
||||
Query: designQuery,
|
||||
Weight: 0.75,
|
||||
Source: "intent:design",
|
||||
Intent: intent,
|
||||
})
|
||||
}
|
||||
|
||||
case IntentGeneral:
|
||||
// For general queries, try noun phrase extraction
|
||||
// No additional expansion - rely on vocabulary expansion
|
||||
}
|
||||
|
||||
return expansions
|
||||
}
|
||||
|
||||
// expandByVocabulary finds similar terms from the observation vocabulary.
|
||||
func (e *Expander) expandByVocabulary(ctx context.Context, query string, minSimilarity float64) []ExpandedQuery {
|
||||
e.vocabMu.RLock()
|
||||
defer e.vocabMu.RUnlock()
|
||||
|
||||
if len(e.vocabulary) == 0 || e.embedSvc == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Embed the query
|
||||
queryEmb, err := e.embedSvc.Embed(query)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to embed query for vocabulary expansion")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find similar vocabulary terms
|
||||
type scoredTerm struct {
|
||||
entry VocabEntry
|
||||
score float64
|
||||
}
|
||||
|
||||
var similar []scoredTerm
|
||||
for i, entry := range e.vocabulary {
|
||||
if i >= len(e.vocabVectors) {
|
||||
break
|
||||
}
|
||||
|
||||
score := cosineSimilarity(queryEmb, e.vocabVectors[i])
|
||||
if score >= minSimilarity {
|
||||
similar = append(similar, scoredTerm{entry: entry, score: score})
|
||||
}
|
||||
}
|
||||
|
||||
if len(similar) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sort by score (descending) using bubble sort
|
||||
for i := 0; i < len(similar)-1; i++ {
|
||||
for j := i + 1; j < len(similar); j++ {
|
||||
if similar[j].score > similar[i].score {
|
||||
similar[i], similar[j] = similar[j], similar[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create expansion by combining top similar terms with query
|
||||
var expansions []ExpandedQuery
|
||||
if len(similar) > 0 {
|
||||
// Take top 2 similar terms and combine with original key terms
|
||||
keyTerms := extractKeyTerms(query)
|
||||
for i := 0; i < min(2, len(similar)); i++ {
|
||||
term := similar[i].entry.Term
|
||||
// Don't add if term is already in query
|
||||
if strings.Contains(strings.ToLower(query), strings.ToLower(term)) {
|
||||
continue
|
||||
}
|
||||
|
||||
combinedQuery := strings.Join(keyTerms, " ") + " " + term
|
||||
expansions = append(expansions, ExpandedQuery{
|
||||
Query: combinedQuery,
|
||||
Weight: 0.7 * similar[i].score * similar[i].entry.Weight,
|
||||
Source: "vocabulary:" + term,
|
||||
Intent: IntentGeneral,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return expansions
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
// extractKeyTerms extracts meaningful terms from a query.
|
||||
func extractKeyTerms(query string) []string {
|
||||
// Common stop words to filter out
|
||||
stopWords := map[string]bool{
|
||||
"a": true, "an": true, "the": true, "is": true, "are": true,
|
||||
"was": true, "were": true, "be": true, "been": true, "being": true,
|
||||
"have": true, "has": true, "had": true, "do": true, "does": true,
|
||||
"did": true, "will": true, "would": true, "could": true, "should": true,
|
||||
"may": true, "might": true, "must": true, "can": true,
|
||||
"i": true, "me": true, "my": true, "we": true, "our": true,
|
||||
"you": true, "your": true, "it": true, "its": true,
|
||||
"this": true, "that": true, "these": true, "those": true,
|
||||
"what": true, "which": true, "who": true, "whom": true,
|
||||
"how": true, "why": true, "when": true, "where": true,
|
||||
"to": true, "for": true, "with": true, "about": true, "from": true,
|
||||
"in": true, "on": true, "at": true, "by": true, "of": true,
|
||||
"and": true, "or": true, "but": true, "if": true, "then": true,
|
||||
}
|
||||
|
||||
// Split and filter
|
||||
words := strings.Fields(strings.ToLower(query))
|
||||
var terms []string
|
||||
|
||||
for _, word := range words {
|
||||
// Remove punctuation
|
||||
word = strings.Trim(word, ".,?!;:'\"()[]{}")
|
||||
if len(word) < 2 {
|
||||
continue
|
||||
}
|
||||
if stopWords[word] {
|
||||
continue
|
||||
}
|
||||
terms = append(terms, word)
|
||||
}
|
||||
|
||||
return terms
|
||||
}
|
||||
|
||||
// makeDeclarative converts a question to a declarative statement.
|
||||
func makeDeclarative(query string) string {
|
||||
query = strings.TrimSpace(query)
|
||||
|
||||
// Remove question mark
|
||||
query = strings.TrimSuffix(query, "?")
|
||||
|
||||
// Handle common question patterns
|
||||
patterns := []struct {
|
||||
prefix string
|
||||
replacement string
|
||||
}{
|
||||
{"how do i ", ""},
|
||||
{"how to ", ""},
|
||||
{"how does ", ""},
|
||||
{"how is ", ""},
|
||||
{"what is ", ""},
|
||||
{"what are ", ""},
|
||||
{"why does ", ""},
|
||||
{"why is ", ""},
|
||||
{"where is ", ""},
|
||||
{"where are ", ""},
|
||||
{"when does ", ""},
|
||||
{"when is ", ""},
|
||||
}
|
||||
|
||||
lower := strings.ToLower(query)
|
||||
for _, p := range patterns {
|
||||
if strings.HasPrefix(lower, p.prefix) {
|
||||
return strings.TrimSpace(query[len(p.prefix):])
|
||||
}
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
// deduplicateExpansions removes duplicate queries while preserving order.
|
||||
func deduplicateExpansions(expansions []ExpandedQuery) []ExpandedQuery {
|
||||
seen := make(map[string]bool)
|
||||
result := make([]ExpandedQuery, 0, len(expansions))
|
||||
|
||||
for _, exp := range expansions {
|
||||
normalized := strings.ToLower(strings.TrimSpace(exp.Query))
|
||||
if !seen[normalized] {
|
||||
seen[normalized] = true
|
||||
result = append(result, exp)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// cosineSimilarity computes cosine similarity between two vectors.
|
||||
func cosineSimilarity(a, b []float32) float64 {
|
||||
if len(a) != len(b) || len(a) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
var dot, normA, normB float64
|
||||
for i := range a {
|
||||
dot += float64(a[i]) * float64(b[i])
|
||||
normA += float64(a[i]) * float64(a[i])
|
||||
normB += float64(b[i]) * float64(b[i])
|
||||
}
|
||||
|
||||
if normA == 0 || normB == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return dot / (sqrt(normA) * sqrt(normB))
|
||||
}
|
||||
|
||||
// sqrt is a simple square root implementation.
|
||||
func sqrt(x float64) float64 {
|
||||
if x <= 0 {
|
||||
return 0
|
||||
}
|
||||
z := x
|
||||
for i := 0; i < 10; i++ {
|
||||
z = (z + x/z) / 2
|
||||
}
|
||||
return z
|
||||
}
|
||||
|
||||
// truncate truncates a string to maxLen characters.
|
||||
func truncate(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
// Package expansion provides context-aware query expansion for improved search recall.
|
||||
package expansion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
// ExpanderSuite tests the Expander functionality.
|
||||
type ExpanderSuite struct {
|
||||
suite.Suite
|
||||
expander *Expander
|
||||
}
|
||||
|
||||
func TestExpanderSuite(t *testing.T) {
|
||||
suite.Run(t, new(ExpanderSuite))
|
||||
}
|
||||
|
||||
func (s *ExpanderSuite) SetupTest() {
|
||||
// Create expander without embedding service for basic tests
|
||||
s.expander = NewExpander(nil)
|
||||
}
|
||||
|
||||
// TestNewExpander tests expander creation.
|
||||
func (s *ExpanderSuite) TestNewExpander() {
|
||||
e := NewExpander(nil)
|
||||
s.NotNil(e)
|
||||
s.NotNil(e.intentPatterns)
|
||||
s.Nil(e.embedSvc)
|
||||
}
|
||||
|
||||
// TestDetectIntent tests intent detection.
|
||||
func (s *ExpanderSuite) TestDetectIntent() {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
expected QueryIntent
|
||||
}{
|
||||
// Question intents
|
||||
{"how_question", "how do I implement auth?", IntentQuestion},
|
||||
{"why_question", "why does this fail?", IntentError}, // "fail" triggers error first
|
||||
{"what_question", "what is the purpose of this function?", IntentQuestion},
|
||||
{"question_mark", "the handler for auth?", IntentQuestion},
|
||||
{"explain", "explain the architecture", IntentQuestion},
|
||||
|
||||
// Error intents
|
||||
{"error_word", "authentication error in login", IntentError},
|
||||
{"bug_word", "bug in user registration", IntentError},
|
||||
{"fix_word", "fix the memory leak", IntentError},
|
||||
{"not_working", "login not working", IntentError},
|
||||
{"crash", "application crash on startup", IntentError},
|
||||
|
||||
// Implementation intents
|
||||
{"implement", "implement user authentication", IntentImplementation},
|
||||
{"add_feature", "add new endpoint for users", IntentImplementation},
|
||||
{"create", "create a handler for uploads", IntentImplementation},
|
||||
{"function", "function to validate input", IntentImplementation},
|
||||
|
||||
// Architecture intents
|
||||
{"architecture", "architecture of the system", IntentArchitecture},
|
||||
{"design", "design pattern for observers", IntentArchitecture},
|
||||
{"component", "component structure", IntentArchitecture},
|
||||
{"flow", "data flow in the pipeline", IntentArchitecture},
|
||||
|
||||
// General intents
|
||||
{"general", "user authentication", IntentGeneral},
|
||||
{"empty", "", IntentGeneral},
|
||||
{"simple", "database", IntentGeneral},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.Run(tt.name, func() {
|
||||
result := s.expander.DetectIntent(tt.query)
|
||||
s.Equal(tt.expected, result, "Query: %s", tt.query)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpand tests basic query expansion.
|
||||
func (s *ExpanderSuite) TestExpand() {
|
||||
ctx := context.Background()
|
||||
cfg := DefaultConfig()
|
||||
cfg.EnableVocabularyExpansion = false // Disable for unit test
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
minExpansions int
|
||||
hasOriginal bool
|
||||
expectedIntent QueryIntent
|
||||
}{
|
||||
{"question", "how do I implement auth", 1, true, IntentQuestion},
|
||||
{"error", "fix the bug in login", 1, true, IntentError},
|
||||
{"implementation", "implement user handler", 1, true, IntentImplementation},
|
||||
{"architecture", "architecture design", 1, true, IntentArchitecture},
|
||||
{"general", "database connection", 1, true, IntentGeneral},
|
||||
{"empty", "", 0, false, IntentGeneral},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.Run(tt.name, func() {
|
||||
expansions := s.expander.Expand(ctx, tt.query, cfg)
|
||||
|
||||
if tt.minExpansions == 0 {
|
||||
s.Empty(expansions)
|
||||
return
|
||||
}
|
||||
|
||||
s.GreaterOrEqual(len(expansions), tt.minExpansions)
|
||||
|
||||
if tt.hasOriginal {
|
||||
// First expansion should be the original
|
||||
s.Equal(tt.query, expansions[0].Query)
|
||||
s.Equal(1.0, expansions[0].Weight)
|
||||
s.Equal("original", expansions[0].Source)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpandWithConfig tests expansion with custom config.
|
||||
func (s *ExpanderSuite) TestExpandWithConfig() {
|
||||
ctx := context.Background()
|
||||
|
||||
cfg := Config{
|
||||
MaxExpansions: 2,
|
||||
MinSimilarity: 0.7,
|
||||
EnableVocabularyExpansion: false,
|
||||
}
|
||||
|
||||
expansions := s.expander.Expand(ctx, "how to implement authentication", cfg)
|
||||
s.LessOrEqual(len(expansions), cfg.MaxExpansions)
|
||||
}
|
||||
|
||||
// TestExpandDeduplication tests that duplicates are removed.
|
||||
func (s *ExpanderSuite) TestExpandDeduplication() {
|
||||
ctx := context.Background()
|
||||
cfg := DefaultConfig()
|
||||
cfg.EnableVocabularyExpansion = false
|
||||
|
||||
// Query that might generate duplicate expansions
|
||||
query := "how to fix authentication"
|
||||
expansions := s.expander.Expand(ctx, query, cfg)
|
||||
|
||||
// Check for duplicates
|
||||
seen := make(map[string]bool)
|
||||
for _, exp := range expansions {
|
||||
normalized := exp.Query
|
||||
s.False(seen[normalized], "Duplicate expansion found: %s", exp.Query)
|
||||
seen[normalized] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractKeyTerms tests key term extraction.
|
||||
func TestExtractKeyTerms(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "simple",
|
||||
query: "user authentication handler",
|
||||
expected: []string{"user", "authentication", "handler"},
|
||||
},
|
||||
{
|
||||
name: "with_stop_words",
|
||||
query: "how to implement the user login",
|
||||
expected: []string{"implement", "user", "login"},
|
||||
},
|
||||
{
|
||||
name: "with_punctuation",
|
||||
query: "fix the bug, please!",
|
||||
expected: []string{"fix", "bug", "please"},
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
query: "",
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "only_stop_words",
|
||||
query: "the a an is are",
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "short_words_filtered",
|
||||
query: "a b c auth",
|
||||
expected: []string{"auth"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := extractKeyTerms(tt.query)
|
||||
if tt.expected == nil {
|
||||
assert.Empty(t, result)
|
||||
} else {
|
||||
assert.Equal(t, tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMakeDeclarative tests question to declarative conversion.
|
||||
func TestMakeDeclarative(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "how_do_i",
|
||||
query: "how do I implement auth?",
|
||||
expected: "implement auth",
|
||||
},
|
||||
{
|
||||
name: "how_to",
|
||||
query: "how to fix the bug",
|
||||
expected: "fix the bug",
|
||||
},
|
||||
{
|
||||
name: "what_is",
|
||||
query: "what is the purpose of this?",
|
||||
expected: "the purpose of this",
|
||||
},
|
||||
{
|
||||
name: "why_does",
|
||||
query: "why does this fail?",
|
||||
expected: "this fail",
|
||||
},
|
||||
{
|
||||
name: "already_declarative",
|
||||
query: "user authentication",
|
||||
expected: "user authentication",
|
||||
},
|
||||
{
|
||||
name: "question_mark_only",
|
||||
query: "authentication?",
|
||||
expected: "authentication",
|
||||
},
|
||||
{
|
||||
name: "case_insensitive",
|
||||
query: "How To Fix Auth?",
|
||||
expected: "Fix Auth",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := makeDeclarative(tt.query)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeduplicateExpansions tests deduplication.
|
||||
func TestDeduplicateExpansions(t *testing.T) {
|
||||
expansions := []ExpandedQuery{
|
||||
{Query: "auth handler", Weight: 1.0},
|
||||
{Query: "AUTH HANDLER", Weight: 0.8}, // Duplicate (case insensitive)
|
||||
{Query: "auth handler ", Weight: 0.7}, // Duplicate (whitespace)
|
||||
{Query: "user auth", Weight: 0.6},
|
||||
}
|
||||
|
||||
result := deduplicateExpansions(expansions)
|
||||
assert.Len(t, result, 2) // "auth handler" and "user auth"
|
||||
assert.Equal(t, "auth handler", result[0].Query)
|
||||
assert.Equal(t, 1.0, result[0].Weight) // First one preserved
|
||||
assert.Equal(t, "user auth", result[1].Query)
|
||||
}
|
||||
|
||||
// TestCosineSimilarity tests cosine similarity calculation.
|
||||
func TestCosineSimilarity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a []float32
|
||||
b []float32
|
||||
expected float64
|
||||
delta float64
|
||||
}{
|
||||
{
|
||||
name: "identical_vectors",
|
||||
a: []float32{1, 0, 0},
|
||||
b: []float32{1, 0, 0},
|
||||
expected: 1.0,
|
||||
delta: 0.001,
|
||||
},
|
||||
{
|
||||
name: "orthogonal_vectors",
|
||||
a: []float32{1, 0, 0},
|
||||
b: []float32{0, 1, 0},
|
||||
expected: 0.0,
|
||||
delta: 0.001,
|
||||
},
|
||||
{
|
||||
name: "opposite_vectors",
|
||||
a: []float32{1, 0, 0},
|
||||
b: []float32{-1, 0, 0},
|
||||
expected: -1.0,
|
||||
delta: 0.001,
|
||||
},
|
||||
{
|
||||
name: "similar_vectors",
|
||||
a: []float32{1, 1, 0},
|
||||
b: []float32{1, 0, 0},
|
||||
expected: 0.707,
|
||||
delta: 0.01,
|
||||
},
|
||||
{
|
||||
name: "empty_vectors",
|
||||
a: []float32{},
|
||||
b: []float32{},
|
||||
expected: 0.0,
|
||||
delta: 0.001,
|
||||
},
|
||||
{
|
||||
name: "different_lengths",
|
||||
a: []float32{1, 0},
|
||||
b: []float32{1, 0, 0},
|
||||
expected: 0.0,
|
||||
delta: 0.001,
|
||||
},
|
||||
{
|
||||
name: "zero_vector",
|
||||
a: []float32{0, 0, 0},
|
||||
b: []float32{1, 1, 1},
|
||||
expected: 0.0,
|
||||
delta: 0.001,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := cosineSimilarity(tt.a, tt.b)
|
||||
assert.InDelta(t, tt.expected, result, tt.delta)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig tests default configuration.
|
||||
func TestDefaultConfig(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
assert.Equal(t, 4, cfg.MaxExpansions)
|
||||
assert.Equal(t, 0.5, cfg.MinSimilarity)
|
||||
assert.True(t, cfg.EnableVocabularyExpansion)
|
||||
}
|
||||
|
||||
// TestExpandedQueryStruct tests ExpandedQuery struct.
|
||||
func TestExpandedQueryStruct(t *testing.T) {
|
||||
eq := ExpandedQuery{
|
||||
Query: "test query",
|
||||
Weight: 0.85,
|
||||
Source: "vocabulary:auth",
|
||||
Intent: IntentQuestion,
|
||||
}
|
||||
|
||||
assert.Equal(t, "test query", eq.Query)
|
||||
assert.Equal(t, 0.85, eq.Weight)
|
||||
assert.Equal(t, "vocabulary:auth", eq.Source)
|
||||
assert.Equal(t, IntentQuestion, eq.Intent)
|
||||
}
|
||||
|
||||
// TestVocabEntry tests VocabEntry struct.
|
||||
func TestVocabEntry(t *testing.T) {
|
||||
ve := VocabEntry{
|
||||
Term: "authentication",
|
||||
Weight: 0.9,
|
||||
Source: "concept",
|
||||
}
|
||||
|
||||
assert.Equal(t, "authentication", ve.Term)
|
||||
assert.Equal(t, 0.9, ve.Weight)
|
||||
assert.Equal(t, "concept", ve.Source)
|
||||
}
|
||||
|
||||
// TestIntentConstants tests intent constant values.
|
||||
func TestIntentConstants(t *testing.T) {
|
||||
assert.Equal(t, QueryIntent("question"), IntentQuestion)
|
||||
assert.Equal(t, QueryIntent("error"), IntentError)
|
||||
assert.Equal(t, QueryIntent("implementation"), IntentImplementation)
|
||||
assert.Equal(t, QueryIntent("architecture"), IntentArchitecture)
|
||||
assert.Equal(t, QueryIntent("general"), IntentGeneral)
|
||||
}
|
||||
|
||||
// TestTruncate tests the truncate helper.
|
||||
func TestTruncate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
maxLen int
|
||||
expected string
|
||||
}{
|
||||
{"short", "hello", 10, "hello"},
|
||||
{"exact", "hello", 5, "hello"},
|
||||
{"long", "hello world", 5, "hello..."},
|
||||
{"empty", "", 10, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := truncate(tt.input, tt.maxLen)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSqrt tests the sqrt helper.
|
||||
func TestSqrt(t *testing.T) {
|
||||
tests := []struct {
|
||||
input float64
|
||||
expected float64
|
||||
delta float64
|
||||
}{
|
||||
{4.0, 2.0, 0.001},
|
||||
{9.0, 3.0, 0.001},
|
||||
{16.0, 4.0, 0.001},
|
||||
{2.0, 1.414, 0.01},
|
||||
{0.0, 0.0, 0.001},
|
||||
{-1.0, 0.0, 0.001},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run("", func(t *testing.T) {
|
||||
result := sqrt(tt.input)
|
||||
assert.InDelta(t, tt.expected, result, tt.delta)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpandByIntentError tests error intent expansion.
|
||||
func (s *ExpanderSuite) TestExpandByIntentError() {
|
||||
expansions := s.expander.expandByIntent("fix authentication bug", IntentError)
|
||||
s.NotEmpty(expansions)
|
||||
|
||||
// Should have solution-oriented expansion
|
||||
hasSolution := false
|
||||
for _, exp := range expansions {
|
||||
if exp.Source == "intent:solution" {
|
||||
hasSolution = true
|
||||
break
|
||||
}
|
||||
}
|
||||
s.True(hasSolution)
|
||||
}
|
||||
|
||||
// TestExpandByIntentQuestion tests question intent expansion.
|
||||
func (s *ExpanderSuite) TestExpandByIntentQuestion() {
|
||||
expansions := s.expander.expandByIntent("how do I implement auth", IntentQuestion)
|
||||
s.NotEmpty(expansions)
|
||||
|
||||
// Should have declarative expansion
|
||||
hasDeclarative := false
|
||||
for _, exp := range expansions {
|
||||
if exp.Source == "intent:declarative" {
|
||||
hasDeclarative = true
|
||||
break
|
||||
}
|
||||
}
|
||||
s.True(hasDeclarative)
|
||||
}
|
||||
|
||||
// TestExpandByIntentImplementation tests implementation intent expansion.
|
||||
func (s *ExpanderSuite) TestExpandByIntentImplementation() {
|
||||
expansions := s.expander.expandByIntent("implement user handler", IntentImplementation)
|
||||
s.NotEmpty(expansions)
|
||||
|
||||
// Should have how expansion
|
||||
hasHow := false
|
||||
for _, exp := range expansions {
|
||||
if exp.Source == "intent:how" {
|
||||
hasHow = true
|
||||
break
|
||||
}
|
||||
}
|
||||
s.True(hasHow)
|
||||
}
|
||||
|
||||
// TestExpandByIntentArchitecture tests architecture intent expansion.
|
||||
func (s *ExpanderSuite) TestExpandByIntentArchitecture() {
|
||||
expansions := s.expander.expandByIntent("system architecture design", IntentArchitecture)
|
||||
s.NotEmpty(expansions)
|
||||
|
||||
// Should have design expansion
|
||||
hasDesign := false
|
||||
for _, exp := range expansions {
|
||||
if exp.Source == "intent:design" {
|
||||
hasDesign = true
|
||||
break
|
||||
}
|
||||
}
|
||||
s.True(hasDesign)
|
||||
}
|
||||
|
||||
// TestExpandByIntentGeneral tests general intent returns no expansions.
|
||||
func (s *ExpanderSuite) TestExpandByIntentGeneral() {
|
||||
expansions := s.expander.expandByIntent("database", IntentGeneral)
|
||||
s.Empty(expansions) // General intent doesn't add intent-based expansions
|
||||
}
|
||||
|
||||
// TestEmptyVocabulary tests expansion with empty vocabulary.
|
||||
func (s *ExpanderSuite) TestEmptyVocabulary() {
|
||||
ctx := context.Background()
|
||||
expansions := s.expander.expandByVocabulary(ctx, "test query", 0.5)
|
||||
s.Empty(expansions)
|
||||
}
|
||||
|
||||
// TestIntentPatternsExist tests that all intents have patterns.
|
||||
func (s *ExpanderSuite) TestIntentPatternsExist() {
|
||||
s.NotEmpty(s.expander.intentPatterns[IntentQuestion])
|
||||
s.NotEmpty(s.expander.intentPatterns[IntentError])
|
||||
s.NotEmpty(s.expander.intentPatterns[IntentImplementation])
|
||||
s.NotEmpty(s.expander.intentPatterns[IntentArchitecture])
|
||||
}
|
||||
+29
-15
@@ -35,20 +35,21 @@ func NewManager(
|
||||
|
||||
// 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
|
||||
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
|
||||
ExcludeSuperseded bool // If true, exclude observations that have been superseded
|
||||
}
|
||||
|
||||
// SearchResult represents a unified search result.
|
||||
@@ -126,6 +127,10 @@ func (m *Manager) vectorSearch(ctx context.Context, params SearchParams) (*Unifi
|
||||
obs, err := m.observationStore.GetObservationsByIDs(ctx, obsIDs, params.OrderBy, 0)
|
||||
if err == nil {
|
||||
for _, o := range obs {
|
||||
// Skip superseded observations when requested
|
||||
if params.ExcludeSuperseded && o.IsSuperseded {
|
||||
continue
|
||||
}
|
||||
results = append(results, m.observationToResult(o, params.Format))
|
||||
}
|
||||
}
|
||||
@@ -167,7 +172,16 @@ func (m *Manager) filterSearch(ctx context.Context, params SearchParams) (*Unifi
|
||||
|
||||
// Search observations
|
||||
if params.Type == "" || params.Type == "observations" {
|
||||
obs, err := m.observationStore.GetRecentObservations(ctx, params.Project, params.Limit)
|
||||
var obs []*models.Observation
|
||||
var err error
|
||||
|
||||
// Use active observations (excluding superseded) when requested
|
||||
if params.ExcludeSuperseded {
|
||||
obs, err = m.observationStore.GetActiveObservations(ctx, params.Project, params.Limit)
|
||||
} else {
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user