mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-06-05 23:03:55 +00:00
4f4b4ac70f
- [x] Add language-specific chunkers with AST parsing (Go, Python, TypeScript) - [x] Implement chunking manager to dispatch files to appropriate chunkers - [x] Integrate code chunks into vector sync for semantic search - [x] Add tree-sitter dependency for Python/TypeScript parsing - [x] Reorder struct fields for consistency across codebase - [x] Rename error variables to follow Go conventions (err → unmarshalErr, etc.) - [x] Add code chunk metadata to vector documents (language, symbol name, line ranges) - [x] Update worker service to initialize chunking pipeline with all three languages
474 lines
12 KiB
Go
474 lines
12 KiB
Go
// Package models contains domain models for claude-mnemonic.
|
|
package models
|
|
|
|
import (
|
|
"database/sql"
|
|
"testing"
|
|
)
|
|
|
|
func TestDetectFileOverlapRelation(t *testing.T) {
|
|
tests := []struct {
|
|
newer *Observation
|
|
older *Observation
|
|
name string
|
|
wantRelType RelationType
|
|
wantMinConfid float64
|
|
wantRelation bool
|
|
}{
|
|
{
|
|
name: "no file overlap",
|
|
newer: &Observation{
|
|
ID: 1,
|
|
FilesModified: []string{"file1.go", "file2.go"},
|
|
},
|
|
older: &Observation{
|
|
ID: 2,
|
|
FilesModified: []string{"file3.go", "file4.go"},
|
|
},
|
|
wantRelation: false,
|
|
},
|
|
{
|
|
name: "shared modified files",
|
|
newer: &Observation{
|
|
ID: 1,
|
|
Type: ObsTypeRefactor,
|
|
FilesModified: []string{"shared.go", "file2.go"},
|
|
},
|
|
older: &Observation{
|
|
ID: 2,
|
|
Type: ObsTypeRefactor,
|
|
FilesModified: []string{"shared.go", "file4.go"},
|
|
},
|
|
wantRelation: true,
|
|
wantRelType: RelationSupersedes,
|
|
wantMinConfid: 0.5,
|
|
},
|
|
{
|
|
name: "bugfix on feature file",
|
|
newer: &Observation{
|
|
ID: 1,
|
|
Type: ObsTypeBugfix,
|
|
FilesModified: []string{"feature.go"},
|
|
},
|
|
older: &Observation{
|
|
ID: 2,
|
|
Type: ObsTypeFeature,
|
|
FilesModified: []string{"feature.go"},
|
|
},
|
|
wantRelation: true,
|
|
wantRelType: RelationFixes,
|
|
wantMinConfid: 0.6,
|
|
},
|
|
{
|
|
name: "newer reads older modified",
|
|
newer: &Observation{
|
|
ID: 1,
|
|
Type: ObsTypeChange,
|
|
FilesRead: []string{"dep.go"},
|
|
FilesModified: []string{"caller.go"},
|
|
},
|
|
older: &Observation{
|
|
ID: 2,
|
|
Type: ObsTypeDecision,
|
|
FilesModified: []string{"dep.go"},
|
|
},
|
|
wantRelation: true,
|
|
wantMinConfid: 0.5,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := DetectFileOverlapRelation(tt.newer, tt.older)
|
|
|
|
if tt.wantRelation {
|
|
if result == nil {
|
|
t.Fatal("expected relation, got nil")
|
|
}
|
|
if tt.wantRelType != "" && result.RelationType != tt.wantRelType {
|
|
t.Errorf("relation type = %v, want %v", result.RelationType, tt.wantRelType)
|
|
}
|
|
if result.Confidence < tt.wantMinConfid {
|
|
t.Errorf("confidence = %v, want at least %v", result.Confidence, tt.wantMinConfid)
|
|
}
|
|
if result.DetectionSource != DetectionSourceFileOverlap {
|
|
t.Errorf("source = %v, want %v", result.DetectionSource, DetectionSourceFileOverlap)
|
|
}
|
|
} else {
|
|
if result != nil {
|
|
t.Errorf("expected no relation, got %+v", result)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDetectConceptOverlapRelation(t *testing.T) {
|
|
tests := []struct {
|
|
newer *Observation
|
|
older *Observation
|
|
name string
|
|
wantMinConfid float64
|
|
wantRelation bool
|
|
}{
|
|
{
|
|
name: "no concept overlap",
|
|
newer: &Observation{
|
|
ID: 1,
|
|
Concepts: []string{"auth", "api"},
|
|
},
|
|
older: &Observation{
|
|
ID: 2,
|
|
Concepts: []string{"database", "caching"},
|
|
},
|
|
wantRelation: false,
|
|
},
|
|
{
|
|
name: "shared concepts",
|
|
newer: &Observation{
|
|
ID: 1,
|
|
Concepts: []string{"security", "auth"},
|
|
},
|
|
older: &Observation{
|
|
ID: 2,
|
|
Concepts: []string{"security", "validation"},
|
|
},
|
|
wantRelation: true,
|
|
wantMinConfid: 0.4, // security is a high-value concept
|
|
},
|
|
{
|
|
name: "multiple shared concepts",
|
|
newer: &Observation{
|
|
ID: 1,
|
|
Concepts: []string{"auth", "api", "validation"},
|
|
},
|
|
older: &Observation{
|
|
ID: 2,
|
|
Concepts: []string{"auth", "api", "database"},
|
|
},
|
|
wantRelation: true,
|
|
wantMinConfid: 0.5,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := DetectConceptOverlapRelation(tt.newer, tt.older)
|
|
|
|
if tt.wantRelation {
|
|
if result == nil {
|
|
t.Fatal("expected relation, got nil")
|
|
}
|
|
if result.Confidence < tt.wantMinConfid {
|
|
t.Errorf("confidence = %v, want at least %v", result.Confidence, tt.wantMinConfid)
|
|
}
|
|
if result.DetectionSource != DetectionSourceConceptOverlap {
|
|
t.Errorf("source = %v, want %v", result.DetectionSource, DetectionSourceConceptOverlap)
|
|
}
|
|
} else {
|
|
if result != nil {
|
|
t.Errorf("expected no relation, got %+v", result)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDetectTypeProgressionRelation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
newerType ObservationType
|
|
olderType ObservationType
|
|
wantRelType RelationType
|
|
wantRelation bool
|
|
}{
|
|
{
|
|
name: "bugfix fixes discovery",
|
|
newerType: ObsTypeBugfix,
|
|
olderType: ObsTypeDiscovery,
|
|
wantRelation: true,
|
|
wantRelType: RelationFixes,
|
|
},
|
|
{
|
|
name: "bugfix fixes feature",
|
|
newerType: ObsTypeBugfix,
|
|
olderType: ObsTypeFeature,
|
|
wantRelation: true,
|
|
wantRelType: RelationFixes,
|
|
},
|
|
{
|
|
name: "feature depends on decision",
|
|
newerType: ObsTypeFeature,
|
|
olderType: ObsTypeDecision,
|
|
wantRelation: true,
|
|
wantRelType: RelationDependsOn,
|
|
},
|
|
{
|
|
name: "refactor evolves from discovery",
|
|
newerType: ObsTypeRefactor,
|
|
olderType: ObsTypeDiscovery,
|
|
wantRelation: true,
|
|
wantRelType: RelationEvolvesFrom,
|
|
},
|
|
{
|
|
name: "no progression discovery to bugfix",
|
|
newerType: ObsTypeDiscovery,
|
|
olderType: ObsTypeBugfix,
|
|
wantRelation: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
newer := &Observation{ID: 1, Type: tt.newerType}
|
|
older := &Observation{ID: 2, Type: tt.olderType}
|
|
result := DetectTypeProgressionRelation(newer, older)
|
|
|
|
if tt.wantRelation {
|
|
if result == nil {
|
|
t.Fatal("expected relation, got nil")
|
|
}
|
|
if result.RelationType != tt.wantRelType {
|
|
t.Errorf("relation type = %v, want %v", result.RelationType, tt.wantRelType)
|
|
}
|
|
if result.DetectionSource != DetectionSourceTypeProgression {
|
|
t.Errorf("source = %v, want %v", result.DetectionSource, DetectionSourceTypeProgression)
|
|
}
|
|
} else {
|
|
if result != nil {
|
|
t.Errorf("expected no relation, got %+v", result)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDetectTemporalProximityRelation(t *testing.T) {
|
|
baseTime := int64(1700000000000) // some base epoch ms
|
|
|
|
tests := []struct {
|
|
name string
|
|
newerSession string
|
|
olderSession string
|
|
newerTime int64
|
|
olderTime int64
|
|
wantRelation bool
|
|
}{
|
|
{
|
|
name: "same session close time",
|
|
newerSession: "session-1",
|
|
olderSession: "session-1",
|
|
newerTime: baseTime + 60000, // 1 minute later
|
|
olderTime: baseTime,
|
|
wantRelation: true,
|
|
},
|
|
{
|
|
name: "same session far apart",
|
|
newerSession: "session-1",
|
|
olderSession: "session-1",
|
|
newerTime: baseTime + 600000, // 10 minutes later
|
|
olderTime: baseTime,
|
|
wantRelation: false, // > 5 minutes
|
|
},
|
|
{
|
|
name: "different sessions close time",
|
|
newerSession: "session-1",
|
|
olderSession: "session-2",
|
|
newerTime: baseTime + 30000,
|
|
olderTime: baseTime,
|
|
wantRelation: false, // different sessions
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
newer := &Observation{
|
|
ID: 1,
|
|
SDKSessionID: tt.newerSession,
|
|
CreatedAtEpoch: tt.newerTime,
|
|
}
|
|
older := &Observation{
|
|
ID: 2,
|
|
SDKSessionID: tt.olderSession,
|
|
CreatedAtEpoch: tt.olderTime,
|
|
}
|
|
result := DetectTemporalProximityRelation(newer, older)
|
|
|
|
if tt.wantRelation {
|
|
if result == nil {
|
|
t.Fatal("expected relation, got nil")
|
|
}
|
|
if result.DetectionSource != DetectionSourceTemporalProximity {
|
|
t.Errorf("source = %v, want %v", result.DetectionSource, DetectionSourceTemporalProximity)
|
|
}
|
|
} else {
|
|
if result != nil {
|
|
t.Errorf("expected no relation, got %+v", result)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDetectNarrativeMentionRelation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
narrative string
|
|
wantRelType RelationType
|
|
wantRelation bool
|
|
}{
|
|
{
|
|
name: "fixes language",
|
|
narrative: "This change fixes the issue with authentication",
|
|
wantRelation: true,
|
|
wantRelType: RelationFixes,
|
|
},
|
|
{
|
|
name: "causes language",
|
|
narrative: "This decision caused unexpected side effects",
|
|
wantRelation: true,
|
|
wantRelType: RelationCauses,
|
|
},
|
|
{
|
|
name: "supersedes language",
|
|
narrative: "This approach supersedes the previous workaround",
|
|
wantRelation: true,
|
|
wantRelType: RelationSupersedes,
|
|
},
|
|
{
|
|
name: "depends on language",
|
|
narrative: "This feature depends on the authentication module",
|
|
wantRelation: true,
|
|
wantRelType: RelationDependsOn,
|
|
},
|
|
{
|
|
name: "no relationship language",
|
|
narrative: "Added new feature for user management",
|
|
wantRelation: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
newer := &Observation{
|
|
ID: 1,
|
|
Narrative: sql.NullString{String: tt.narrative, Valid: true},
|
|
}
|
|
older := &Observation{ID: 2}
|
|
result := DetectNarrativeMentionRelation(newer, older)
|
|
|
|
if tt.wantRelation {
|
|
if result == nil {
|
|
t.Fatal("expected relation, got nil")
|
|
}
|
|
if result.RelationType != tt.wantRelType {
|
|
t.Errorf("relation type = %v, want %v", result.RelationType, tt.wantRelType)
|
|
}
|
|
if result.DetectionSource != DetectionSourceNarrativeMention {
|
|
t.Errorf("source = %v, want %v", result.DetectionSource, DetectionSourceNarrativeMention)
|
|
}
|
|
} else {
|
|
if result != nil {
|
|
t.Errorf("expected no relation, got %+v", result)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDetectRelationsWithExisting(t *testing.T) {
|
|
newer := &Observation{
|
|
ID: 1,
|
|
SDKSessionID: "session-1",
|
|
Project: "test-project",
|
|
Type: ObsTypeBugfix,
|
|
FilesModified: []string{"auth.go"},
|
|
Concepts: []string{"security", "auth"},
|
|
Narrative: sql.NullString{String: "Fixed security issue in auth module", Valid: true},
|
|
}
|
|
|
|
existing := []*Observation{
|
|
{
|
|
ID: 2,
|
|
SDKSessionID: "session-1",
|
|
Project: "test-project",
|
|
Type: ObsTypeDiscovery,
|
|
FilesModified: []string{"auth.go"},
|
|
Concepts: []string{"security"},
|
|
},
|
|
{
|
|
ID: 3,
|
|
SDKSessionID: "session-2",
|
|
Project: "test-project",
|
|
Type: ObsTypeFeature,
|
|
FilesModified: []string{"other.go"},
|
|
Concepts: []string{"api"},
|
|
},
|
|
{
|
|
ID: 4,
|
|
SDKSessionID: "session-1",
|
|
Project: "other-project", // different project
|
|
Type: ObsTypeDiscovery,
|
|
},
|
|
}
|
|
|
|
results := DetectRelationsWithExisting(newer, existing, 0.4)
|
|
|
|
// Should find relation with observation 2 (file overlap + concept overlap + type progression)
|
|
// Should not find relation with observation 3 (no overlap)
|
|
// Should not find relation with observation 4 (different project)
|
|
|
|
if len(results) == 0 {
|
|
t.Fatal("expected at least one relation")
|
|
}
|
|
|
|
// Check that we found relation with observation 2
|
|
foundObs2 := false
|
|
for _, r := range results {
|
|
if r.TargetID == 2 {
|
|
foundObs2 = true
|
|
// Should be high confidence due to multiple signals
|
|
if r.Confidence < 0.5 {
|
|
t.Errorf("expected higher confidence for obs 2, got %v", r.Confidence)
|
|
}
|
|
}
|
|
// Should not find relation with obs 4
|
|
if r.TargetID == 4 {
|
|
t.Error("should not find relation with different project")
|
|
}
|
|
}
|
|
|
|
if !foundObs2 {
|
|
t.Error("expected to find relation with observation 2")
|
|
}
|
|
}
|
|
|
|
func TestNewObservationRelation(t *testing.T) {
|
|
rel := NewObservationRelation(1, 2, RelationFixes, 0.8, DetectionSourceFileOverlap, "test reason")
|
|
|
|
if rel.SourceID != 1 {
|
|
t.Errorf("SourceID = %v, want 1", rel.SourceID)
|
|
}
|
|
if rel.TargetID != 2 {
|
|
t.Errorf("TargetID = %v, want 2", rel.TargetID)
|
|
}
|
|
if rel.RelationType != RelationFixes {
|
|
t.Errorf("RelationType = %v, want %v", rel.RelationType, RelationFixes)
|
|
}
|
|
if rel.Confidence != 0.8 {
|
|
t.Errorf("Confidence = %v, want 0.8", rel.Confidence)
|
|
}
|
|
if rel.DetectionSource != DetectionSourceFileOverlap {
|
|
t.Errorf("DetectionSource = %v, want %v", rel.DetectionSource, DetectionSourceFileOverlap)
|
|
}
|
|
if rel.Reason != "test reason" {
|
|
t.Errorf("Reason = %v, want 'test reason'", rel.Reason)
|
|
}
|
|
if rel.CreatedAt == "" {
|
|
t.Error("CreatedAt should be set")
|
|
}
|
|
if rel.CreatedAtEpoch == 0 {
|
|
t.Error("CreatedAtEpoch should be set")
|
|
}
|
|
}
|