mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-10-12 04:55:18 +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,258 @@
|
||||
// Package models contains domain models for claude-mnemonic.
|
||||
package models
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ConflictType represents the type of conflict between observations.
|
||||
type ConflictType string
|
||||
|
||||
const (
|
||||
// ConflictSuperseded means newer observation supersedes older one (same topic, updated info).
|
||||
ConflictSuperseded ConflictType = "superseded"
|
||||
// ConflictContradicts means observations contain contradictory information.
|
||||
ConflictContradicts ConflictType = "contradicts"
|
||||
// ConflictOutdatedPattern means an outdated pattern/practice was identified.
|
||||
ConflictOutdatedPattern ConflictType = "outdated_pattern"
|
||||
)
|
||||
|
||||
// ConflictResolution indicates which observation to prefer.
|
||||
type ConflictResolution string
|
||||
|
||||
const (
|
||||
// ResolutionPreferNewer means prefer the newer observation.
|
||||
ResolutionPreferNewer ConflictResolution = "prefer_newer"
|
||||
// ResolutionPreferOlder means prefer the older observation (rare).
|
||||
ResolutionPreferOlder ConflictResolution = "prefer_older"
|
||||
// ResolutionManual means manual review is needed.
|
||||
ResolutionManual ConflictResolution = "manual"
|
||||
)
|
||||
|
||||
// ObservationConflict tracks conflicting observations.
|
||||
type ObservationConflict struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
NewerObsID int64 `db:"newer_obs_id" json:"newer_obs_id"`
|
||||
OlderObsID int64 `db:"older_obs_id" json:"older_obs_id"`
|
||||
ConflictType ConflictType `db:"conflict_type" json:"conflict_type"`
|
||||
Resolution ConflictResolution `db:"resolution" json:"resolution"`
|
||||
Reason string `db:"reason" json:"reason"`
|
||||
DetectedAt string `db:"detected_at" json:"detected_at"`
|
||||
DetectedAtEpoch int64 `db:"detected_at_epoch" json:"detected_at_epoch"`
|
||||
Resolved bool `db:"resolved" json:"resolved"`
|
||||
ResolvedAt *string `db:"resolved_at" json:"resolved_at,omitempty"`
|
||||
}
|
||||
|
||||
// ConflictDetectionResult contains the result of conflict detection.
|
||||
type ConflictDetectionResult struct {
|
||||
HasConflict bool
|
||||
Type ConflictType
|
||||
Resolution ConflictResolution
|
||||
Reason string
|
||||
OlderObsIDs []int64 // IDs of observations that conflict with the new one
|
||||
}
|
||||
|
||||
// NewObservationConflict creates a new conflict record.
|
||||
func NewObservationConflict(newerID, olderID int64, conflictType ConflictType, resolution ConflictResolution, reason string) *ObservationConflict {
|
||||
now := time.Now()
|
||||
return &ObservationConflict{
|
||||
NewerObsID: newerID,
|
||||
OlderObsID: olderID,
|
||||
ConflictType: conflictType,
|
||||
Resolution: resolution,
|
||||
Reason: reason,
|
||||
DetectedAt: now.Format(time.RFC3339),
|
||||
DetectedAtEpoch: now.UnixMilli(),
|
||||
Resolved: false,
|
||||
}
|
||||
}
|
||||
|
||||
// CorrectionPatterns contains regex patterns that indicate explicit corrections.
|
||||
var CorrectionPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)\bactually[,\s]+that\s+was\s+wrong\b`),
|
||||
regexp.MustCompile(`(?i)\bactually[,\s]+that's\s+(wrong|incorrect|not\s+right)\b`),
|
||||
regexp.MustCompile(`(?i)\bpreviously\s+(said|mentioned|noted)\s+.*\s+but\b`),
|
||||
regexp.MustCompile(`(?i)\bcorrection:\s*`),
|
||||
regexp.MustCompile(`(?i)\bignore\s+(the\s+)?(previous|earlier)\b`),
|
||||
regexp.MustCompile(`(?i)\bdisregard\s+(the\s+)?(previous|earlier)\b`),
|
||||
regexp.MustCompile(`(?i)\bwas\s+(wrong|incorrect|mistaken)\b`),
|
||||
regexp.MustCompile(`(?i)\bturns\s+out\s+.*(wrong|incorrect|not\s+the\s+case)\b`),
|
||||
regexp.MustCompile(`(?i)\b(supersedes|replaces|overrides)\s+(the\s+)?(previous|earlier|old)\b`),
|
||||
regexp.MustCompile(`(?i)\b(don't|do\s+not)\s+use\s+.*\s+anymore\b`),
|
||||
regexp.MustCompile(`(?i)\bno\s+longer\s+(valid|applicable|correct|recommended)\b`),
|
||||
regexp.MustCompile(`(?i)\bdeprecated\s+(approach|method|pattern|way)\b`),
|
||||
regexp.MustCompile(`(?i)\bshould\s+have\s+(been|used)\b.*instead\b`),
|
||||
regexp.MustCompile(`(?i)\bbetter\s+(approach|way|method|solution)\s+is\b`),
|
||||
}
|
||||
|
||||
// OpposingChangePatterns detects add/remove conflicts.
|
||||
var OpposingChangePatterns = map[string]string{
|
||||
"add": "remove",
|
||||
"added": "removed",
|
||||
"create": "delete",
|
||||
"created": "deleted",
|
||||
"enable": "disable",
|
||||
"enabled": "disabled",
|
||||
"include": "exclude",
|
||||
"allow": "deny",
|
||||
"permit": "block",
|
||||
}
|
||||
|
||||
// DetectExplicitCorrection checks if text contains explicit correction language.
|
||||
func DetectExplicitCorrection(text string) (bool, string) {
|
||||
for _, pattern := range CorrectionPatterns {
|
||||
if match := pattern.FindString(text); match != "" {
|
||||
return true, "Explicit correction detected: " + match
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// DetectOpposingFileChanges checks if two observations have opposing changes on the same file.
|
||||
func DetectOpposingFileChanges(newer, older *Observation) (bool, string) {
|
||||
// Check for overlapping modified files
|
||||
newerFiles := make(map[string]bool)
|
||||
for _, f := range newer.FilesModified {
|
||||
newerFiles[f] = true
|
||||
}
|
||||
|
||||
var overlappingFiles []string
|
||||
for _, f := range older.FilesModified {
|
||||
if newerFiles[f] {
|
||||
overlappingFiles = append(overlappingFiles, f)
|
||||
}
|
||||
}
|
||||
|
||||
if len(overlappingFiles) == 0 {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Check for opposing action words in titles/narratives
|
||||
newerText := strings.ToLower(newer.Title.String + " " + newer.Narrative.String)
|
||||
olderText := strings.ToLower(older.Title.String + " " + older.Narrative.String)
|
||||
|
||||
for action, opposite := range OpposingChangePatterns {
|
||||
if (strings.Contains(newerText, action) && strings.Contains(olderText, opposite)) ||
|
||||
(strings.Contains(newerText, opposite) && strings.Contains(olderText, action)) {
|
||||
return true, "Opposing changes on files: " + strings.Join(overlappingFiles, ", ")
|
||||
}
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// DetectConceptTagMismatch checks if observations have same concepts but different recommendations.
|
||||
func DetectConceptTagMismatch(newer, older *Observation) (bool, string) {
|
||||
// Find overlapping concepts
|
||||
newerConcepts := make(map[string]bool)
|
||||
for _, c := range newer.Concepts {
|
||||
newerConcepts[c] = true
|
||||
}
|
||||
|
||||
var overlapping []string
|
||||
for _, c := range older.Concepts {
|
||||
if newerConcepts[c] {
|
||||
overlapping = append(overlapping, c)
|
||||
}
|
||||
}
|
||||
|
||||
if len(overlapping) == 0 {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Check if same file was modified and concepts overlap
|
||||
// This suggests the newer observation may update the approach
|
||||
newerFiles := make(map[string]bool)
|
||||
for _, f := range newer.FilesModified {
|
||||
newerFiles[f] = true
|
||||
}
|
||||
for _, f := range older.FilesModified {
|
||||
if newerFiles[f] {
|
||||
// Same file modified with same concepts - likely an update
|
||||
return true, "Same concepts (" + strings.Join(overlapping, ", ") + ") with overlapping file changes"
|
||||
}
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// DetectConflict performs comprehensive conflict detection between a new observation
|
||||
// and an existing one. Returns detection result.
|
||||
func DetectConflict(newer, older *Observation) *ConflictDetectionResult {
|
||||
result := &ConflictDetectionResult{
|
||||
HasConflict: false,
|
||||
}
|
||||
|
||||
// 1. Check for explicit correction language in newer observation
|
||||
if newer.Narrative.Valid {
|
||||
if isCorrection, reason := DetectExplicitCorrection(newer.Narrative.String); isCorrection {
|
||||
result.HasConflict = true
|
||||
result.Type = ConflictContradicts
|
||||
result.Resolution = ResolutionPreferNewer
|
||||
result.Reason = reason
|
||||
result.OlderObsIDs = append(result.OlderObsIDs, older.ID)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// Check title as well
|
||||
if newer.Title.Valid {
|
||||
if isCorrection, reason := DetectExplicitCorrection(newer.Title.String); isCorrection {
|
||||
result.HasConflict = true
|
||||
result.Type = ConflictContradicts
|
||||
result.Resolution = ResolutionPreferNewer
|
||||
result.Reason = reason
|
||||
result.OlderObsIDs = append(result.OlderObsIDs, older.ID)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check for opposing file changes
|
||||
if isOpposing, reason := DetectOpposingFileChanges(newer, older); isOpposing {
|
||||
result.HasConflict = true
|
||||
result.Type = ConflictSuperseded
|
||||
result.Resolution = ResolutionPreferNewer
|
||||
result.Reason = reason
|
||||
result.OlderObsIDs = append(result.OlderObsIDs, older.ID)
|
||||
return result
|
||||
}
|
||||
|
||||
// 3. Check for concept tag mismatches with same files
|
||||
if isMismatch, reason := DetectConceptTagMismatch(newer, older); isMismatch {
|
||||
result.HasConflict = true
|
||||
result.Type = ConflictSuperseded
|
||||
result.Resolution = ResolutionPreferNewer
|
||||
result.Reason = reason
|
||||
result.OlderObsIDs = append(result.OlderObsIDs, older.ID)
|
||||
return result
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// DetectConflictsWithExisting checks a new observation against a list of existing observations.
|
||||
// Returns all detected conflicts.
|
||||
func DetectConflictsWithExisting(newer *Observation, existing []*Observation) []*ConflictDetectionResult {
|
||||
var results []*ConflictDetectionResult
|
||||
|
||||
for _, older := range existing {
|
||||
// Skip self-comparison
|
||||
if older.ID == newer.ID {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only compare within same project (or both global)
|
||||
if newer.Project != older.Project && newer.Scope != ScopeGlobal && older.Scope != ScopeGlobal {
|
||||
continue
|
||||
}
|
||||
|
||||
result := DetectConflict(newer, older)
|
||||
if result.HasConflict {
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
// Package models contains domain models for claude-mnemonic.
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
// ConflictSuite is a test suite for conflict detection operations.
|
||||
type ConflictSuite struct {
|
||||
suite.Suite
|
||||
}
|
||||
|
||||
func TestConflictSuite(t *testing.T) {
|
||||
suite.Run(t, new(ConflictSuite))
|
||||
}
|
||||
|
||||
// TestConflictTypeConstants tests conflict type constants.
|
||||
func (s *ConflictSuite) TestConflictTypeConstants() {
|
||||
s.Equal(ConflictType("superseded"), ConflictSuperseded)
|
||||
s.Equal(ConflictType("contradicts"), ConflictContradicts)
|
||||
s.Equal(ConflictType("outdated_pattern"), ConflictOutdatedPattern)
|
||||
}
|
||||
|
||||
// TestResolutionConstants tests resolution constants.
|
||||
func (s *ConflictSuite) TestResolutionConstants() {
|
||||
s.Equal(ConflictResolution("prefer_newer"), ResolutionPreferNewer)
|
||||
s.Equal(ConflictResolution("prefer_older"), ResolutionPreferOlder)
|
||||
s.Equal(ConflictResolution("manual"), ResolutionManual)
|
||||
}
|
||||
|
||||
// TestNewObservationConflict tests conflict creation.
|
||||
func (s *ConflictSuite) TestNewObservationConflict() {
|
||||
conflict := NewObservationConflict(2, 1, ConflictSuperseded, ResolutionPreferNewer, "Test reason")
|
||||
|
||||
s.Equal(int64(2), conflict.NewerObsID)
|
||||
s.Equal(int64(1), conflict.OlderObsID)
|
||||
s.Equal(ConflictSuperseded, conflict.ConflictType)
|
||||
s.Equal(ResolutionPreferNewer, conflict.Resolution)
|
||||
s.Equal("Test reason", conflict.Reason)
|
||||
s.False(conflict.Resolved)
|
||||
s.NotEmpty(conflict.DetectedAt)
|
||||
s.Greater(conflict.DetectedAtEpoch, int64(0))
|
||||
}
|
||||
|
||||
// TestDetectExplicitCorrection_TableDriven tests explicit correction detection.
|
||||
func (s *ConflictSuite) TestDetectExplicitCorrection_TableDriven() {
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
expectMatch bool
|
||||
expectPattern string
|
||||
}{
|
||||
{
|
||||
name: "actually that was wrong",
|
||||
text: "Actually, that was wrong - we should use a different approach",
|
||||
expectMatch: true,
|
||||
expectPattern: "actually, that was wrong",
|
||||
},
|
||||
{
|
||||
name: "correction prefix",
|
||||
text: "Correction: the previous implementation had a bug",
|
||||
expectMatch: true,
|
||||
expectPattern: "correction:",
|
||||
},
|
||||
{
|
||||
name: "ignore previous",
|
||||
text: "Please ignore the previous recommendation",
|
||||
expectMatch: true,
|
||||
expectPattern: "ignore",
|
||||
},
|
||||
{
|
||||
name: "disregard earlier",
|
||||
text: "Disregard the earlier suggestion, it was flawed",
|
||||
expectMatch: true,
|
||||
expectPattern: "disregard",
|
||||
},
|
||||
{
|
||||
name: "was wrong",
|
||||
text: "The original approach was wrong",
|
||||
expectMatch: true,
|
||||
expectPattern: "was wrong",
|
||||
},
|
||||
{
|
||||
name: "no longer valid",
|
||||
text: "This method is no longer valid after the refactor",
|
||||
expectMatch: true,
|
||||
expectPattern: "no longer valid",
|
||||
},
|
||||
{
|
||||
name: "deprecated approach",
|
||||
text: "This is a deprecated approach that should not be used",
|
||||
expectMatch: true,
|
||||
expectPattern: "deprecated approach",
|
||||
},
|
||||
{
|
||||
name: "better approach is",
|
||||
text: "A better approach is to use the new API",
|
||||
expectMatch: true,
|
||||
expectPattern: "better approach is",
|
||||
},
|
||||
{
|
||||
name: "normal text - no correction",
|
||||
text: "This is a normal observation about the code",
|
||||
expectMatch: false,
|
||||
},
|
||||
{
|
||||
name: "empty text",
|
||||
text: "",
|
||||
expectMatch: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.Run(tt.name, func() {
|
||||
found, reason := DetectExplicitCorrection(tt.text)
|
||||
s.Equal(tt.expectMatch, found)
|
||||
if tt.expectMatch {
|
||||
s.Contains(reason, "Explicit correction detected")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectOpposingFileChanges_TableDriven tests opposing file change detection.
|
||||
func (s *ConflictSuite) TestDetectOpposingFileChanges_TableDriven() {
|
||||
tests := []struct {
|
||||
name string
|
||||
newerObs *Observation
|
||||
olderObs *Observation
|
||||
expectConflict bool
|
||||
}{
|
||||
{
|
||||
name: "add then remove - conflict",
|
||||
newerObs: &Observation{
|
||||
Title: sql.NullString{String: "Remove authentication middleware", Valid: true},
|
||||
Narrative: sql.NullString{String: "Removed the auth middleware from handlers", Valid: true},
|
||||
FilesModified: []string{"middleware.go", "handler.go"},
|
||||
},
|
||||
olderObs: &Observation{
|
||||
Title: sql.NullString{String: "Add authentication middleware", Valid: true},
|
||||
Narrative: sql.NullString{String: "Added auth middleware to secure endpoints", Valid: true},
|
||||
FilesModified: []string{"middleware.go", "handler.go"},
|
||||
},
|
||||
expectConflict: true,
|
||||
},
|
||||
{
|
||||
name: "enable then disable - conflict",
|
||||
newerObs: &Observation{
|
||||
Title: sql.NullString{String: "Disable caching feature", Valid: true},
|
||||
Narrative: sql.NullString{String: "Disabled caching due to issues", Valid: true},
|
||||
FilesModified: []string{"cache.go"},
|
||||
},
|
||||
olderObs: &Observation{
|
||||
Title: sql.NullString{String: "Enable caching feature", Valid: true},
|
||||
Narrative: sql.NullString{String: "Enabled caching for performance", Valid: true},
|
||||
FilesModified: []string{"cache.go"},
|
||||
},
|
||||
expectConflict: true,
|
||||
},
|
||||
{
|
||||
name: "different files - no conflict",
|
||||
newerObs: &Observation{
|
||||
Title: sql.NullString{String: "Remove old code", Valid: true},
|
||||
Narrative: sql.NullString{String: "Removed deprecated functions", Valid: true},
|
||||
FilesModified: []string{"old.go"},
|
||||
},
|
||||
olderObs: &Observation{
|
||||
Title: sql.NullString{String: "Add new feature", Valid: true},
|
||||
Narrative: sql.NullString{String: "Added new functions", Valid: true},
|
||||
FilesModified: []string{"new.go"},
|
||||
},
|
||||
expectConflict: false,
|
||||
},
|
||||
{
|
||||
name: "same files but no opposing keywords - no conflict",
|
||||
newerObs: &Observation{
|
||||
Title: sql.NullString{String: "Update handler logic", Valid: true},
|
||||
Narrative: sql.NullString{String: "Updated the handler implementation", Valid: true},
|
||||
FilesModified: []string{"handler.go"},
|
||||
},
|
||||
olderObs: &Observation{
|
||||
Title: sql.NullString{String: "Fix handler bug", Valid: true},
|
||||
Narrative: sql.NullString{String: "Fixed a bug in handler", Valid: true},
|
||||
FilesModified: []string{"handler.go"},
|
||||
},
|
||||
expectConflict: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.Run(tt.name, func() {
|
||||
found, _ := DetectOpposingFileChanges(tt.newerObs, tt.olderObs)
|
||||
s.Equal(tt.expectConflict, found)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectConceptTagMismatch_TableDriven tests concept tag mismatch detection.
|
||||
func (s *ConflictSuite) TestDetectConceptTagMismatch_TableDriven() {
|
||||
tests := []struct {
|
||||
name string
|
||||
newerObs *Observation
|
||||
olderObs *Observation
|
||||
expectConflict bool
|
||||
}{
|
||||
{
|
||||
name: "same concepts and files - conflict",
|
||||
newerObs: &Observation{
|
||||
Concepts: []string{"security", "authentication"},
|
||||
FilesModified: []string{"auth.go"},
|
||||
},
|
||||
olderObs: &Observation{
|
||||
Concepts: []string{"security", "authentication"},
|
||||
FilesModified: []string{"auth.go"},
|
||||
},
|
||||
expectConflict: true,
|
||||
},
|
||||
{
|
||||
name: "overlapping concepts different files - no conflict",
|
||||
newerObs: &Observation{
|
||||
Concepts: []string{"security"},
|
||||
FilesModified: []string{"new_auth.go"},
|
||||
},
|
||||
olderObs: &Observation{
|
||||
Concepts: []string{"security"},
|
||||
FilesModified: []string{"old_auth.go"},
|
||||
},
|
||||
expectConflict: false,
|
||||
},
|
||||
{
|
||||
name: "different concepts same files - no conflict",
|
||||
newerObs: &Observation{
|
||||
Concepts: []string{"performance"},
|
||||
FilesModified: []string{"handler.go"},
|
||||
},
|
||||
olderObs: &Observation{
|
||||
Concepts: []string{"testing"},
|
||||
FilesModified: []string{"handler.go"},
|
||||
},
|
||||
expectConflict: false,
|
||||
},
|
||||
{
|
||||
name: "no overlapping concepts or files - no conflict",
|
||||
newerObs: &Observation{
|
||||
Concepts: []string{"security"},
|
||||
FilesModified: []string{"auth.go"},
|
||||
},
|
||||
olderObs: &Observation{
|
||||
Concepts: []string{"testing"},
|
||||
FilesModified: []string{"test.go"},
|
||||
},
|
||||
expectConflict: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.Run(tt.name, func() {
|
||||
found, _ := DetectConceptTagMismatch(tt.newerObs, tt.olderObs)
|
||||
s.Equal(tt.expectConflict, found)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectConflict tests comprehensive conflict detection.
|
||||
func (s *ConflictSuite) TestDetectConflict() {
|
||||
// Test explicit correction takes precedence
|
||||
newer := &Observation{
|
||||
ID: 2,
|
||||
Project: "test",
|
||||
Narrative: sql.NullString{String: "Actually, that was wrong. We should use a different approach.", Valid: true},
|
||||
}
|
||||
older := &Observation{
|
||||
ID: 1,
|
||||
Project: "test",
|
||||
}
|
||||
|
||||
result := DetectConflict(newer, older)
|
||||
s.True(result.HasConflict)
|
||||
s.Equal(ConflictContradicts, result.Type)
|
||||
s.Equal(ResolutionPreferNewer, result.Resolution)
|
||||
s.Contains(result.Reason, "Explicit correction")
|
||||
}
|
||||
|
||||
// TestDetectConflictsWithExisting tests conflict detection against multiple observations.
|
||||
func (s *ConflictSuite) TestDetectConflictsWithExisting() {
|
||||
newer := &Observation{
|
||||
ID: 3,
|
||||
Project: "test",
|
||||
Narrative: sql.NullString{String: "Actually, that was wrong", Valid: true},
|
||||
Concepts: []string{"security"},
|
||||
FilesModified: []string{"auth.go"},
|
||||
}
|
||||
|
||||
existing := []*Observation{
|
||||
{
|
||||
ID: 1,
|
||||
Project: "test",
|
||||
Concepts: []string{"security"},
|
||||
FilesModified: []string{"auth.go"},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Project: "test",
|
||||
Concepts: []string{"testing"},
|
||||
FilesModified: []string{"test.go"},
|
||||
},
|
||||
{
|
||||
ID: 3, // Same as newer - should be skipped
|
||||
Project: "test",
|
||||
},
|
||||
}
|
||||
|
||||
results := DetectConflictsWithExisting(newer, existing)
|
||||
|
||||
// Should find conflicts with obs 1 (concepts + files overlap + correction language)
|
||||
// but not with obs 2 (different concepts/files) or obs 3 (same ID)
|
||||
s.GreaterOrEqual(len(results), 1)
|
||||
|
||||
// At least one result should reference obs 1
|
||||
foundObs1 := false
|
||||
for _, r := range results {
|
||||
for _, id := range r.OlderObsIDs {
|
||||
if id == 1 {
|
||||
foundObs1 = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
s.True(foundObs1)
|
||||
}
|
||||
|
||||
// TestDetectConflictsWithExisting_DifferentProjects tests that different projects don't conflict.
|
||||
func (s *ConflictSuite) TestDetectConflictsWithExisting_DifferentProjects() {
|
||||
newer := &Observation{
|
||||
ID: 2,
|
||||
Project: "project-a",
|
||||
Scope: ScopeProject,
|
||||
Narrative: sql.NullString{String: "Actually, that was wrong", Valid: true},
|
||||
}
|
||||
|
||||
existing := []*Observation{
|
||||
{
|
||||
ID: 1,
|
||||
Project: "project-b",
|
||||
Scope: ScopeProject,
|
||||
},
|
||||
}
|
||||
|
||||
results := DetectConflictsWithExisting(newer, existing)
|
||||
s.Empty(results) // Different projects should not conflict
|
||||
}
|
||||
|
||||
// TestDetectConflictsWithExisting_GlobalScope tests that global observations can conflict across projects.
|
||||
func (s *ConflictSuite) TestDetectConflictsWithExisting_GlobalScope() {
|
||||
newer := &Observation{
|
||||
ID: 2,
|
||||
Project: "project-a",
|
||||
Scope: ScopeGlobal,
|
||||
Narrative: sql.NullString{String: "Actually, that was wrong", Valid: true},
|
||||
Concepts: []string{"security"},
|
||||
FilesModified: []string{"auth.go"},
|
||||
}
|
||||
|
||||
existing := []*Observation{
|
||||
{
|
||||
ID: 1,
|
||||
Project: "project-b",
|
||||
Scope: ScopeGlobal, // Global scope allows cross-project conflict detection
|
||||
Concepts: []string{"security"},
|
||||
FilesModified: []string{"auth.go"},
|
||||
},
|
||||
}
|
||||
|
||||
results := DetectConflictsWithExisting(newer, existing)
|
||||
s.GreaterOrEqual(len(results), 1) // Global scope allows conflict detection
|
||||
}
|
||||
|
||||
// TestCorrectionPatterns_Compiled ensures all patterns compile correctly.
|
||||
func TestCorrectionPatterns_Compiled(t *testing.T) {
|
||||
// This test verifies that all correction patterns are valid regexps
|
||||
// If any pattern fails to compile, the package won't load
|
||||
assert.NotEmpty(t, CorrectionPatterns)
|
||||
for i, pattern := range CorrectionPatterns {
|
||||
assert.NotNil(t, pattern, "Pattern %d should not be nil", i)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpposingChangePatterns tests the opposing change pattern map.
|
||||
func TestOpposingChangePatterns(t *testing.T) {
|
||||
assert.NotEmpty(t, OpposingChangePatterns)
|
||||
assert.Equal(t, "remove", OpposingChangePatterns["add"])
|
||||
assert.Equal(t, "removed", OpposingChangePatterns["added"])
|
||||
assert.Equal(t, "delete", OpposingChangePatterns["create"])
|
||||
assert.Equal(t, "disable", OpposingChangePatterns["enable"])
|
||||
}
|
||||
|
||||
// TestObservationConflict_Fields tests field access.
|
||||
func TestObservationConflict_Fields(t *testing.T) {
|
||||
conflict := &ObservationConflict{
|
||||
ID: 1,
|
||||
NewerObsID: 10,
|
||||
OlderObsID: 5,
|
||||
ConflictType: ConflictSuperseded,
|
||||
Resolution: ResolutionPreferNewer,
|
||||
Reason: "Test reason",
|
||||
DetectedAt: "2024-01-01T00:00:00Z",
|
||||
DetectedAtEpoch: 1704067200000,
|
||||
Resolved: false,
|
||||
}
|
||||
|
||||
assert.Equal(t, int64(1), conflict.ID)
|
||||
assert.Equal(t, int64(10), conflict.NewerObsID)
|
||||
assert.Equal(t, int64(5), conflict.OlderObsID)
|
||||
assert.Equal(t, ConflictSuperseded, conflict.ConflictType)
|
||||
assert.Equal(t, ResolutionPreferNewer, conflict.Resolution)
|
||||
assert.False(t, conflict.Resolved)
|
||||
}
|
||||
@@ -139,6 +139,16 @@ type Observation struct {
|
||||
CreatedAt string `db:"created_at" json:"created_at"`
|
||||
CreatedAtEpoch int64 `db:"created_at_epoch" json:"created_at_epoch"`
|
||||
IsStale bool `db:"-" json:"is_stale,omitempty"`
|
||||
|
||||
// Importance scoring fields
|
||||
ImportanceScore float64 `db:"importance_score" json:"importance_score"`
|
||||
UserFeedback int `db:"user_feedback" json:"user_feedback"`
|
||||
RetrievalCount int `db:"retrieval_count" json:"retrieval_count"`
|
||||
LastRetrievedAt sql.NullInt64 `db:"last_retrieved_at_epoch" json:"last_retrieved_at_epoch,omitempty"`
|
||||
ScoreUpdatedAt sql.NullInt64 `db:"score_updated_at_epoch" json:"score_updated_at_epoch,omitempty"`
|
||||
|
||||
// Conflict detection fields
|
||||
IsSuperseded bool `db:"is_superseded" json:"is_superseded,omitempty"`
|
||||
}
|
||||
|
||||
// ParsedObservation represents an observation parsed from SDK response XML.
|
||||
@@ -205,6 +215,16 @@ type ObservationJSON struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
CreatedAtEpoch int64 `json:"created_at_epoch"`
|
||||
IsStale bool `json:"is_stale,omitempty"`
|
||||
|
||||
// Importance scoring fields
|
||||
ImportanceScore float64 `json:"importance_score"`
|
||||
UserFeedback int `json:"user_feedback"`
|
||||
RetrievalCount int `json:"retrieval_count"`
|
||||
LastRetrievedAt int64 `json:"last_retrieved_at_epoch,omitempty"`
|
||||
ScoreUpdatedAt int64 `json:"score_updated_at_epoch,omitempty"`
|
||||
|
||||
// Conflict detection fields
|
||||
IsSuperseded bool `json:"is_superseded,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler for Observation.
|
||||
@@ -225,6 +245,12 @@ func (o *Observation) MarshalJSON() ([]byte, error) {
|
||||
CreatedAt: o.CreatedAt,
|
||||
CreatedAtEpoch: o.CreatedAtEpoch,
|
||||
IsStale: o.IsStale,
|
||||
// Importance scoring fields
|
||||
ImportanceScore: o.ImportanceScore,
|
||||
UserFeedback: o.UserFeedback,
|
||||
RetrievalCount: o.RetrievalCount,
|
||||
// Conflict detection fields
|
||||
IsSuperseded: o.IsSuperseded,
|
||||
}
|
||||
if o.Title.Valid {
|
||||
j.Title = o.Title.String
|
||||
@@ -238,6 +264,12 @@ func (o *Observation) MarshalJSON() ([]byte, error) {
|
||||
if o.PromptNumber.Valid {
|
||||
j.PromptNumber = o.PromptNumber.Int64
|
||||
}
|
||||
if o.LastRetrievedAt.Valid {
|
||||
j.LastRetrievedAt = o.LastRetrievedAt.Int64
|
||||
}
|
||||
if o.ScoreUpdatedAt.Valid {
|
||||
j.ScoreUpdatedAt = o.ScoreUpdatedAt.Int64
|
||||
}
|
||||
return json.Marshal(j)
|
||||
}
|
||||
|
||||
@@ -268,9 +300,28 @@ func NewObservation(sdkSessionID, project string, parsed *ParsedObservation, pro
|
||||
DiscoveryTokens: discoveryTokens,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
// Importance scoring: new observations start with score 1.0
|
||||
ImportanceScore: 1.0,
|
||||
UserFeedback: 0,
|
||||
RetrievalCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ToMap converts the observation to a map for JSON response building.
|
||||
// This allows adding extra fields like similarity scores.
|
||||
func (o *Observation) ToMap() map[string]interface{} {
|
||||
// Marshal to JSON then unmarshal to map (uses MarshalJSON for proper conversion)
|
||||
data, err := json.Marshal(o)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"id": o.ID, "error": err.Error()}
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return map[string]interface{}{"id": o.ID, "error": err.Error()}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// CheckStaleness checks if an observation is stale based on current file mtimes.
|
||||
// Returns true if any tracked file has been modified since the observation was created.
|
||||
func (o *Observation) CheckStaleness(currentMtimes map[string]int64) bool {
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
// Package models contains domain models for claude-mnemonic.
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PatternType represents the category of detected pattern.
|
||||
type PatternType string
|
||||
|
||||
const (
|
||||
// PatternTypeBug represents recurring bug patterns (e.g., "nil handling oversight").
|
||||
PatternTypeBug PatternType = "bug"
|
||||
// PatternTypeRefactor represents recurring refactoring approaches (e.g., "interface extraction").
|
||||
PatternTypeRefactor PatternType = "refactor"
|
||||
// PatternTypeArchitecture represents consistent architectural patterns.
|
||||
PatternTypeArchitecture PatternType = "architecture"
|
||||
// PatternTypeAntiPattern represents identified anti-patterns to avoid.
|
||||
PatternTypeAntiPattern PatternType = "anti-pattern"
|
||||
// PatternTypeBestPractice represents best practices that work consistently.
|
||||
PatternTypeBestPractice PatternType = "best-practice"
|
||||
)
|
||||
|
||||
// PatternStatus represents the lifecycle status of a pattern.
|
||||
type PatternStatus string
|
||||
|
||||
const (
|
||||
// PatternStatusActive means the pattern is actively being tracked and can be referenced.
|
||||
PatternStatusActive PatternStatus = "active"
|
||||
// PatternStatusDeprecated means the pattern has been superseded or is no longer relevant.
|
||||
PatternStatusDeprecated PatternStatus = "deprecated"
|
||||
// PatternStatusMerged means this pattern was merged into another pattern.
|
||||
PatternStatusMerged PatternStatus = "merged"
|
||||
)
|
||||
|
||||
// Pattern represents a recurring pattern detected across observations.
|
||||
// This enables Claude to reference historical insights: "I've encountered this pattern 12 times."
|
||||
type Pattern struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
Name string `db:"name" json:"name"` // e.g., "State Management Anti-Pattern"
|
||||
Type PatternType `db:"type" json:"type"` // bug, refactor, architecture, etc.
|
||||
Description sql.NullString `db:"description" json:"description"` // Detailed description
|
||||
Signature JSONStringArray `db:"signature" json:"signature"` // Keyword clusters for detection
|
||||
Recommendation sql.NullString `db:"recommendation" json:"recommendation"` // What works for this pattern
|
||||
Frequency int `db:"frequency" json:"frequency"` // How many times encountered
|
||||
Projects JSONStringArray `db:"projects" json:"projects"` // Projects where this pattern was seen
|
||||
ObservationIDs JSONInt64Array `db:"observation_ids" json:"observation_ids"` // Source observation IDs
|
||||
Status PatternStatus `db:"status" json:"status"` // active, deprecated, merged
|
||||
MergedIntoID sql.NullInt64 `db:"merged_into_id" json:"merged_into_id,omitempty"`
|
||||
Confidence float64 `db:"confidence" json:"confidence"` // Detection confidence (0.0-1.0)
|
||||
LastSeenAt string `db:"last_seen_at" json:"last_seen_at"` // Last time pattern was detected
|
||||
LastSeenEpoch int64 `db:"last_seen_at_epoch" json:"last_seen_at_epoch"`
|
||||
CreatedAt string `db:"created_at" json:"created_at"`
|
||||
CreatedAtEpoch int64 `db:"created_at_epoch" json:"created_at_epoch"`
|
||||
}
|
||||
|
||||
// JSONInt64Array is a custom type for handling JSON int64 arrays in SQLite.
|
||||
type JSONInt64Array []int64
|
||||
|
||||
// Scan implements sql.Scanner for JSONInt64Array.
|
||||
func (j *JSONInt64Array) Scan(src interface{}) error {
|
||||
if src == nil {
|
||||
*j = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
var data []byte
|
||||
switch v := src.(type) {
|
||||
case string:
|
||||
data = []byte(v)
|
||||
case []byte:
|
||||
data = v
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
*j = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
return json.Unmarshal(data, j)
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer for JSONInt64Array.
|
||||
func (j JSONInt64Array) Value() (driver.Value, error) {
|
||||
if j == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return json.Marshal(j)
|
||||
}
|
||||
|
||||
// PatternJSON is a JSON-friendly representation of Pattern.
|
||||
type PatternJSON struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type PatternType `json:"type"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Signature []string `json:"signature,omitempty"`
|
||||
Recommendation string `json:"recommendation,omitempty"`
|
||||
Frequency int `json:"frequency"`
|
||||
Projects []string `json:"projects,omitempty"`
|
||||
ObservationIDs []int64 `json:"observation_ids,omitempty"`
|
||||
Status PatternStatus `json:"status"`
|
||||
MergedIntoID int64 `json:"merged_into_id,omitempty"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
LastSeenAt string `json:"last_seen_at"`
|
||||
LastSeenEpoch int64 `json:"last_seen_at_epoch"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
CreatedAtEpoch int64 `json:"created_at_epoch"`
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler for Pattern.
|
||||
func (p *Pattern) MarshalJSON() ([]byte, error) {
|
||||
j := PatternJSON{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Type: p.Type,
|
||||
Signature: p.Signature,
|
||||
Frequency: p.Frequency,
|
||||
Projects: p.Projects,
|
||||
ObservationIDs: p.ObservationIDs,
|
||||
Status: p.Status,
|
||||
Confidence: p.Confidence,
|
||||
LastSeenAt: p.LastSeenAt,
|
||||
LastSeenEpoch: p.LastSeenEpoch,
|
||||
CreatedAt: p.CreatedAt,
|
||||
CreatedAtEpoch: p.CreatedAtEpoch,
|
||||
}
|
||||
if p.Description.Valid {
|
||||
j.Description = p.Description.String
|
||||
}
|
||||
if p.Recommendation.Valid {
|
||||
j.Recommendation = p.Recommendation.String
|
||||
}
|
||||
if p.MergedIntoID.Valid {
|
||||
j.MergedIntoID = p.MergedIntoID.Int64
|
||||
}
|
||||
return json.Marshal(j)
|
||||
}
|
||||
|
||||
// NewPattern creates a new pattern from detected data.
|
||||
func NewPattern(name string, patternType PatternType, description string, signature []string, project string, observationID int64) *Pattern {
|
||||
now := time.Now()
|
||||
return &Pattern{
|
||||
Name: name,
|
||||
Type: patternType,
|
||||
Description: sql.NullString{String: description, Valid: description != ""},
|
||||
Signature: signature,
|
||||
Frequency: 1,
|
||||
Projects: []string{project},
|
||||
ObservationIDs: []int64{observationID},
|
||||
Status: PatternStatusActive,
|
||||
Confidence: 0.5, // Initial confidence
|
||||
LastSeenAt: now.Format(time.RFC3339),
|
||||
LastSeenEpoch: now.UnixMilli(),
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
// AddOccurrence records a new occurrence of this pattern.
|
||||
func (p *Pattern) AddOccurrence(project string, observationID int64) {
|
||||
p.Frequency++
|
||||
|
||||
// Add project if not already tracked
|
||||
found := false
|
||||
for _, proj := range p.Projects {
|
||||
if proj == project {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
p.Projects = append(p.Projects, project)
|
||||
}
|
||||
|
||||
// Add observation ID if not already tracked
|
||||
for _, id := range p.ObservationIDs {
|
||||
if id == observationID {
|
||||
return
|
||||
}
|
||||
}
|
||||
p.ObservationIDs = append(p.ObservationIDs, observationID)
|
||||
|
||||
// Update confidence based on frequency and cross-project occurrence
|
||||
p.updateConfidence()
|
||||
|
||||
// Update last seen timestamp
|
||||
now := time.Now()
|
||||
p.LastSeenAt = now.Format(time.RFC3339)
|
||||
p.LastSeenEpoch = now.UnixMilli()
|
||||
}
|
||||
|
||||
// updateConfidence adjusts confidence based on frequency and cross-project validation.
|
||||
func (p *Pattern) updateConfidence() {
|
||||
// Base confidence from frequency (logarithmic scaling)
|
||||
freqConfidence := 0.3 + (0.4 * (float64(min(p.Frequency, 10)) / 10.0))
|
||||
|
||||
// Cross-project bonus: patterns seen across multiple projects are more reliable
|
||||
projectBonus := 0.0
|
||||
if len(p.Projects) >= 2 {
|
||||
projectBonus = 0.1
|
||||
}
|
||||
if len(p.Projects) >= 5 {
|
||||
projectBonus = 0.2
|
||||
}
|
||||
|
||||
p.Confidence = min(1.0, freqConfidence+projectBonus)
|
||||
}
|
||||
|
||||
// PatternMatch represents a match between an observation and a potential pattern.
|
||||
type PatternMatch struct {
|
||||
PatternID int64 `json:"pattern_id"`
|
||||
Score float64 `json:"score"` // Match score (0.0-1.0)
|
||||
MatchedOn string `json:"matched_on"` // What triggered the match (concept, keyword, type, etc.)
|
||||
IsNew bool `json:"is_new"` // Whether this would create a new pattern
|
||||
SuggestedName string `json:"suggested_name,omitempty"`
|
||||
}
|
||||
|
||||
// PatternSignatureKeywords are common keywords used in pattern detection.
|
||||
var PatternSignatureKeywords = map[PatternType][]string{
|
||||
PatternTypeBug: {
|
||||
"nil", "null", "undefined", "panic", "crash", "error handling",
|
||||
"race condition", "deadlock", "memory leak", "overflow",
|
||||
"off-by-one", "boundary", "timeout", "concurrency",
|
||||
},
|
||||
PatternTypeRefactor: {
|
||||
"extract", "inline", "rename", "move", "split", "merge",
|
||||
"interface", "abstraction", "decouple", "simplify",
|
||||
"consolidate", "modularize", "encapsulate",
|
||||
},
|
||||
PatternTypeArchitecture: {
|
||||
"layer", "service", "repository", "controller", "handler",
|
||||
"middleware", "dependency injection", "factory", "singleton",
|
||||
"observer", "strategy", "adapter", "facade", "builder",
|
||||
},
|
||||
PatternTypeAntiPattern: {
|
||||
"god class", "spaghetti", "copy paste", "magic number",
|
||||
"hardcoded", "circular dependency", "premature optimization",
|
||||
"over-engineering", "feature envy", "data clump",
|
||||
},
|
||||
PatternTypeBestPractice: {
|
||||
"test", "validation", "logging", "monitoring", "documentation",
|
||||
"error handling", "retry", "timeout", "circuit breaker",
|
||||
"graceful shutdown", "health check", "metrics",
|
||||
},
|
||||
}
|
||||
|
||||
// DetectPatternType analyzes concepts and content to determine pattern type.
|
||||
func DetectPatternType(concepts []string, title, narrative string) PatternType {
|
||||
// Check concepts first
|
||||
for _, concept := range concepts {
|
||||
switch concept {
|
||||
case "anti-pattern":
|
||||
return PatternTypeAntiPattern
|
||||
case "best-practice":
|
||||
return PatternTypeBestPractice
|
||||
case "architecture":
|
||||
return PatternTypeArchitecture
|
||||
case "refactor":
|
||||
return PatternTypeRefactor
|
||||
}
|
||||
}
|
||||
|
||||
// Check for bug-related patterns in content
|
||||
content := title + " " + narrative
|
||||
for _, keyword := range PatternSignatureKeywords[PatternTypeBug] {
|
||||
if containsIgnoreCase(content, keyword) {
|
||||
return PatternTypeBug
|
||||
}
|
||||
}
|
||||
|
||||
// Default to refactor for other patterns
|
||||
return PatternTypeRefactor
|
||||
}
|
||||
|
||||
// containsIgnoreCase checks if text contains substr (case-insensitive).
|
||||
func containsIgnoreCase(text, substr string) bool {
|
||||
textLower := toLower(text)
|
||||
substrLower := toLower(substr)
|
||||
return contains(textLower, substrLower)
|
||||
}
|
||||
|
||||
// Simple implementations to avoid strings package dependency in this file
|
||||
func toLower(s string) string {
|
||||
b := make([]byte, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if 'A' <= c && c <= 'Z' {
|
||||
c += 'a' - 'A'
|
||||
}
|
||||
b[i] = c
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
if len(substr) > len(s) {
|
||||
return false
|
||||
}
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ExtractSignature creates a signature from observation content.
|
||||
func ExtractSignature(concepts []string, title, narrative string) []string {
|
||||
var signature []string
|
||||
|
||||
// Add all concepts
|
||||
signature = append(signature, concepts...)
|
||||
|
||||
// Extract key terms from title (simple word extraction)
|
||||
for _, word := range splitWords(title) {
|
||||
if len(word) > 3 && isSignificantWord(word) {
|
||||
signature = append(signature, toLower(word))
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueStrings(signature)
|
||||
}
|
||||
|
||||
// splitWords is a simple word splitter.
|
||||
func splitWords(s string) []string {
|
||||
var words []string
|
||||
word := ""
|
||||
for _, r := range s {
|
||||
if r == ' ' || r == '-' || r == '_' || r == '.' || r == ',' {
|
||||
if word != "" {
|
||||
words = append(words, word)
|
||||
word = ""
|
||||
}
|
||||
} else {
|
||||
word += string(r)
|
||||
}
|
||||
}
|
||||
if word != "" {
|
||||
words = append(words, word)
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
// isSignificantWord filters out common stop words.
|
||||
func isSignificantWord(word string) bool {
|
||||
stopWords := map[string]bool{
|
||||
"the": true, "and": true, "for": true, "with": true, "that": true,
|
||||
"this": true, "from": true, "have": true, "not": true, "are": true,
|
||||
"was": true, "but": true, "all": true, "can": true, "had": true,
|
||||
"were": true, "been": true, "will": true, "when": true, "what": true,
|
||||
}
|
||||
return !stopWords[toLower(word)]
|
||||
}
|
||||
|
||||
// uniqueStrings returns a slice with duplicate strings removed.
|
||||
func uniqueStrings(s []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var result []string
|
||||
for _, v := range s {
|
||||
if !seen[v] {
|
||||
seen[v] = true
|
||||
result = append(result, v)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// CalculateMatchScore computes similarity between two signatures.
|
||||
func CalculateMatchScore(sig1, sig2 []string) float64 {
|
||||
if len(sig1) == 0 || len(sig2) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
set1 := make(map[string]bool)
|
||||
for _, s := range sig1 {
|
||||
set1[toLower(s)] = true
|
||||
}
|
||||
|
||||
matches := 0
|
||||
for _, s := range sig2 {
|
||||
if set1[toLower(s)] {
|
||||
matches++
|
||||
}
|
||||
}
|
||||
|
||||
// Jaccard similarity
|
||||
unionSize := len(sig1) + len(sig2) - matches
|
||||
if unionSize == 0 {
|
||||
return 0.0
|
||||
}
|
||||
return float64(matches) / float64(unionSize)
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewPattern(t *testing.T) {
|
||||
pattern := NewPattern(
|
||||
"Test Pattern",
|
||||
PatternTypeBug,
|
||||
"A test pattern description",
|
||||
[]string{"nil", "error", "handling"},
|
||||
"test-project",
|
||||
123,
|
||||
)
|
||||
|
||||
if pattern.Name != "Test Pattern" {
|
||||
t.Errorf("Expected name 'Test Pattern', got '%s'", pattern.Name)
|
||||
}
|
||||
if pattern.Type != PatternTypeBug {
|
||||
t.Errorf("Expected type PatternTypeBug, got '%s'", pattern.Type)
|
||||
}
|
||||
if !pattern.Description.Valid || pattern.Description.String != "A test pattern description" {
|
||||
t.Errorf("Description not set correctly")
|
||||
}
|
||||
if len(pattern.Signature) != 3 {
|
||||
t.Errorf("Expected 3 signature elements, got %d", len(pattern.Signature))
|
||||
}
|
||||
if pattern.Frequency != 1 {
|
||||
t.Errorf("Expected frequency 1, got %d", pattern.Frequency)
|
||||
}
|
||||
if len(pattern.Projects) != 1 || pattern.Projects[0] != "test-project" {
|
||||
t.Errorf("Projects not set correctly")
|
||||
}
|
||||
if len(pattern.ObservationIDs) != 1 || pattern.ObservationIDs[0] != 123 {
|
||||
t.Errorf("ObservationIDs not set correctly")
|
||||
}
|
||||
if pattern.Status != PatternStatusActive {
|
||||
t.Errorf("Expected status Active, got '%s'", pattern.Status)
|
||||
}
|
||||
if pattern.Confidence != 0.5 {
|
||||
t.Errorf("Expected initial confidence 0.5, got %f", pattern.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPattern_AddOccurrence(t *testing.T) {
|
||||
pattern := NewPattern("Test", PatternTypeBug, "desc", []string{"test"}, "project1", 1)
|
||||
|
||||
// Add same project occurrence
|
||||
pattern.AddOccurrence("project1", 2)
|
||||
if pattern.Frequency != 2 {
|
||||
t.Errorf("Expected frequency 2, got %d", pattern.Frequency)
|
||||
}
|
||||
if len(pattern.Projects) != 1 {
|
||||
t.Errorf("Expected 1 project (no duplicates), got %d", len(pattern.Projects))
|
||||
}
|
||||
|
||||
// Add different project occurrence
|
||||
pattern.AddOccurrence("project2", 3)
|
||||
if pattern.Frequency != 3 {
|
||||
t.Errorf("Expected frequency 3, got %d", pattern.Frequency)
|
||||
}
|
||||
if len(pattern.Projects) != 2 {
|
||||
t.Errorf("Expected 2 projects, got %d", len(pattern.Projects))
|
||||
}
|
||||
|
||||
// Add duplicate observation ID - should not duplicate
|
||||
pattern.AddOccurrence("project2", 3)
|
||||
if len(pattern.ObservationIDs) != 3 {
|
||||
t.Errorf("Expected 3 observation IDs (no duplicate), got %d", len(pattern.ObservationIDs))
|
||||
}
|
||||
|
||||
// Check confidence increased
|
||||
if pattern.Confidence <= 0.5 {
|
||||
t.Errorf("Expected confidence to increase above 0.5, got %f", pattern.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPattern_ConfidenceCalculation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
frequency int
|
||||
projectCount int
|
||||
minConfidence float64
|
||||
maxConfidence float64
|
||||
}{
|
||||
{"low_frequency", 2, 1, 0.3, 0.5},
|
||||
{"high_frequency", 10, 1, 0.6, 0.8},
|
||||
{"multi_project", 3, 3, 0.4, 0.7},
|
||||
{"high_freq_multi_proj", 10, 5, 0.7, 1.0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pattern := NewPattern("Test", PatternTypeBug, "", []string{}, "proj1", 1)
|
||||
|
||||
// Simulate occurrences
|
||||
for i := 1; i < tt.frequency; i++ {
|
||||
projIdx := i % tt.projectCount
|
||||
if projIdx == 0 {
|
||||
projIdx = 1
|
||||
}
|
||||
pattern.AddOccurrence("proj"+string(rune('0'+projIdx)), int64(i+1))
|
||||
}
|
||||
|
||||
if pattern.Confidence < tt.minConfidence || pattern.Confidence > tt.maxConfidence {
|
||||
t.Errorf("Expected confidence between %f and %f, got %f",
|
||||
tt.minConfidence, tt.maxConfidence, pattern.Confidence)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatternType_Detection(t *testing.T) {
|
||||
tests := []struct {
|
||||
concepts []string
|
||||
title string
|
||||
narrative string
|
||||
expected PatternType
|
||||
}{
|
||||
{[]string{"anti-pattern"}, "", "", PatternTypeAntiPattern},
|
||||
{[]string{"best-practice"}, "", "", PatternTypeBestPractice},
|
||||
{[]string{"architecture"}, "", "", PatternTypeArchitecture},
|
||||
{[]string{"refactor"}, "", "", PatternTypeRefactor},
|
||||
{[]string{}, "nil pointer bug", "", PatternTypeBug},
|
||||
{[]string{}, "Deadlock in concurrent code", "", PatternTypeBug},
|
||||
{[]string{}, "Extract interface", "", PatternTypeRefactor},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.title+"_"+tt.expected.String(), func(t *testing.T) {
|
||||
result := DetectPatternType(tt.concepts, tt.title, tt.narrative)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %s, got %s", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (pt PatternType) String() string {
|
||||
return string(pt)
|
||||
}
|
||||
|
||||
func TestExtractSignature(t *testing.T) {
|
||||
concepts := []string{"error-handling", "security"}
|
||||
title := "Nil Pointer Validation Pattern"
|
||||
narrative := "Always validate before dereferencing"
|
||||
|
||||
signature := ExtractSignature(concepts, title, narrative)
|
||||
|
||||
// Should contain concepts
|
||||
found := false
|
||||
for _, s := range signature {
|
||||
if s == "error-handling" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Expected signature to contain concepts, got %v", signature)
|
||||
}
|
||||
|
||||
// Should contain significant words from title
|
||||
found = false
|
||||
for _, s := range signature {
|
||||
if s == "validation" || s == "pattern" || s == "pointer" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Expected signature to contain title keywords, got %v", signature)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateMatchScore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sig1 []string
|
||||
sig2 []string
|
||||
minScore float64
|
||||
maxScore float64
|
||||
}{
|
||||
{"identical", []string{"a", "b", "c"}, []string{"a", "b", "c"}, 1.0, 1.0},
|
||||
{"partial", []string{"a", "b", "c"}, []string{"a", "b", "d"}, 0.4, 0.6},
|
||||
{"no_match", []string{"a", "b", "c"}, []string{"x", "y", "z"}, 0.0, 0.0},
|
||||
{"empty", []string{}, []string{"a", "b"}, 0.0, 0.0},
|
||||
{"subset", []string{"a", "b"}, []string{"a", "b", "c", "d"}, 0.4, 0.6},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
score := CalculateMatchScore(tt.sig1, tt.sig2)
|
||||
if score < tt.minScore || score > tt.maxScore {
|
||||
t.Errorf("Expected score between %f and %f, got %f",
|
||||
tt.minScore, tt.maxScore, score)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPattern_MarshalJSON(t *testing.T) {
|
||||
pattern := &Pattern{
|
||||
ID: 1,
|
||||
Name: "Test Pattern",
|
||||
Type: PatternTypeBug,
|
||||
Description: sql.NullString{String: "A description", Valid: true},
|
||||
Signature: []string{"a", "b"},
|
||||
Recommendation: sql.NullString{String: "Do this", Valid: true},
|
||||
Frequency: 5,
|
||||
Projects: []string{"proj1", "proj2"},
|
||||
ObservationIDs: []int64{1, 2, 3},
|
||||
Status: PatternStatusActive,
|
||||
MergedIntoID: sql.NullInt64{Int64: 0, Valid: false},
|
||||
Confidence: 0.8,
|
||||
LastSeenAt: time.Now().Format(time.RFC3339),
|
||||
LastSeenEpoch: time.Now().UnixMilli(),
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
CreatedAtEpoch: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(pattern)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal pattern: %v", err)
|
||||
}
|
||||
|
||||
var result PatternJSON
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
t.Fatalf("Failed to unmarshal pattern: %v", err)
|
||||
}
|
||||
|
||||
if result.Name != pattern.Name {
|
||||
t.Errorf("Expected name %s, got %s", pattern.Name, result.Name)
|
||||
}
|
||||
if result.Description != pattern.Description.String {
|
||||
t.Errorf("Expected description %s, got %s", pattern.Description.String, result.Description)
|
||||
}
|
||||
if result.Frequency != pattern.Frequency {
|
||||
t.Errorf("Expected frequency %d, got %d", pattern.Frequency, result.Frequency)
|
||||
}
|
||||
if result.MergedIntoID != 0 {
|
||||
t.Errorf("Expected merged_into_id 0 for invalid NullInt64, got %d", result.MergedIntoID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONInt64Array_Scan(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
expected JSONInt64Array
|
||||
wantErr bool
|
||||
}{
|
||||
{"string_array", "[1, 2, 3]", JSONInt64Array{1, 2, 3}, false},
|
||||
{"bytes_array", []byte("[4, 5, 6]"), JSONInt64Array{4, 5, 6}, false},
|
||||
{"nil", nil, nil, false},
|
||||
{"empty_string", "", nil, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var arr JSONInt64Array
|
||||
err := arr.Scan(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Scan() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if len(arr) != len(tt.expected) {
|
||||
t.Errorf("Expected length %d, got %d", len(tt.expected), len(arr))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONInt64Array_Value(t *testing.T) {
|
||||
arr := JSONInt64Array{1, 2, 3}
|
||||
val, err := arr.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("Value() error = %v", err)
|
||||
}
|
||||
|
||||
bytes, ok := val.([]byte)
|
||||
if !ok {
|
||||
t.Fatalf("Expected []byte, got %T", val)
|
||||
}
|
||||
|
||||
var result []int64
|
||||
if err := json.Unmarshal(bytes, &result); err != nil {
|
||||
t.Fatalf("Failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if len(result) != 3 || result[0] != 1 || result[1] != 2 || result[2] != 3 {
|
||||
t.Errorf("Expected [1, 2, 3], got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatternSignatureKeywords(t *testing.T) {
|
||||
// Verify keywords exist for each type
|
||||
types := []PatternType{
|
||||
PatternTypeBug,
|
||||
PatternTypeRefactor,
|
||||
PatternTypeArchitecture,
|
||||
PatternTypeAntiPattern,
|
||||
PatternTypeBestPractice,
|
||||
}
|
||||
|
||||
for _, pt := range types {
|
||||
keywords := PatternSignatureKeywords[pt]
|
||||
if len(keywords) == 0 {
|
||||
t.Errorf("No keywords defined for pattern type %s", pt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniqueStrings(t *testing.T) {
|
||||
tests := []struct {
|
||||
input []string
|
||||
expected int
|
||||
}{
|
||||
{[]string{"a", "b", "c"}, 3},
|
||||
{[]string{"a", "a", "b"}, 2},
|
||||
{[]string{"a", "a", "a"}, 1},
|
||||
{[]string{}, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := uniqueStrings(tt.input)
|
||||
if len(result) != tt.expected {
|
||||
t.Errorf("uniqueStrings(%v) = %v (len=%d), expected len=%d",
|
||||
tt.input, result, len(result), tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsIgnoreCase(t *testing.T) {
|
||||
tests := []struct {
|
||||
text string
|
||||
substr string
|
||||
expected bool
|
||||
}{
|
||||
{"Hello World", "hello", true},
|
||||
{"Hello World", "WORLD", true},
|
||||
{"Hello World", "xyz", false},
|
||||
{"", "a", false},
|
||||
{"a", "", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := containsIgnoreCase(tt.text, tt.substr)
|
||||
if result != tt.expected {
|
||||
t.Errorf("containsIgnoreCase(%q, %q) = %v, expected %v",
|
||||
tt.text, tt.substr, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
// Package models contains domain models for claude-mnemonic.
|
||||
package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RelationType represents the type of relationship between observations.
|
||||
type RelationType string
|
||||
|
||||
const (
|
||||
// RelationCauses means source observation caused target observation.
|
||||
// Example: "This architectural decision caused this bug"
|
||||
RelationCauses RelationType = "causes"
|
||||
// RelationFixes means source observation fixes target observation.
|
||||
// Example: "This bugfix addresses that discovered issue"
|
||||
RelationFixes RelationType = "fixes"
|
||||
// RelationSupersedes means source observation supersedes target observation.
|
||||
// Example: "This new approach replaces the old workaround"
|
||||
RelationSupersedes RelationType = "supersedes"
|
||||
// RelationDependsOn means source observation depends on target observation.
|
||||
// Example: "This feature relies on that architectural decision"
|
||||
RelationDependsOn RelationType = "depends_on"
|
||||
// RelationRelatesTo means observations are related but no causal relationship.
|
||||
// Example: "Both deal with authentication"
|
||||
RelationRelatesTo RelationType = "relates_to"
|
||||
// RelationEvolvesFrom means source observation evolved from target observation.
|
||||
// Example: "This refined pattern evolved from that initial discovery"
|
||||
RelationEvolvesFrom RelationType = "evolves_from"
|
||||
)
|
||||
|
||||
// AllRelationTypes is the list of all valid relation types.
|
||||
var AllRelationTypes = []RelationType{
|
||||
RelationCauses,
|
||||
RelationFixes,
|
||||
RelationSupersedes,
|
||||
RelationDependsOn,
|
||||
RelationRelatesTo,
|
||||
RelationEvolvesFrom,
|
||||
}
|
||||
|
||||
// RelationDetectionSource indicates how a relationship was detected.
|
||||
type RelationDetectionSource string
|
||||
|
||||
const (
|
||||
// DetectionSourceFileOverlap means relationship was detected via shared file references.
|
||||
DetectionSourceFileOverlap RelationDetectionSource = "file_overlap"
|
||||
// DetectionSourceEmbeddingSimilarity means relationship was detected via vector similarity.
|
||||
DetectionSourceEmbeddingSimilarity RelationDetectionSource = "embedding_similarity"
|
||||
// DetectionSourceTemporalProximity means relationship was detected via close timestamps.
|
||||
DetectionSourceTemporalProximity RelationDetectionSource = "temporal_proximity"
|
||||
// DetectionSourceNarrativeMention means relationship was detected via explicit mentions.
|
||||
DetectionSourceNarrativeMention RelationDetectionSource = "narrative_mention"
|
||||
// DetectionSourceConceptOverlap means relationship was detected via shared concepts.
|
||||
DetectionSourceConceptOverlap RelationDetectionSource = "concept_overlap"
|
||||
// DetectionSourceTypeProgression means relationship was detected via type progression pattern.
|
||||
DetectionSourceTypeProgression RelationDetectionSource = "type_progression"
|
||||
)
|
||||
|
||||
// ObservationRelation represents a directed relationship between two observations.
|
||||
type ObservationRelation struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
SourceID int64 `db:"source_id" json:"source_id"`
|
||||
TargetID int64 `db:"target_id" json:"target_id"`
|
||||
RelationType RelationType `db:"relation_type" json:"relation_type"`
|
||||
Confidence float64 `db:"confidence" json:"confidence"`
|
||||
DetectionSource RelationDetectionSource `db:"detection_source" json:"detection_source"`
|
||||
Reason string `db:"reason" json:"reason,omitempty"`
|
||||
CreatedAt string `db:"created_at" json:"created_at"`
|
||||
CreatedAtEpoch int64 `db:"created_at_epoch" json:"created_at_epoch"`
|
||||
}
|
||||
|
||||
// NewObservationRelation creates a new observation relation.
|
||||
func NewObservationRelation(sourceID, targetID int64, relType RelationType, confidence float64, source RelationDetectionSource, reason string) *ObservationRelation {
|
||||
now := time.Now()
|
||||
return &ObservationRelation{
|
||||
SourceID: sourceID,
|
||||
TargetID: targetID,
|
||||
RelationType: relType,
|
||||
Confidence: confidence,
|
||||
DetectionSource: source,
|
||||
Reason: reason,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
// RelationDetectionResult contains the result of relation detection.
|
||||
type RelationDetectionResult struct {
|
||||
SourceID int64
|
||||
TargetID int64
|
||||
RelationType RelationType
|
||||
Confidence float64
|
||||
DetectionSource RelationDetectionSource
|
||||
Reason string
|
||||
}
|
||||
|
||||
// DetectFileOverlapRelation checks if observations share file references and determines relationship type.
|
||||
func DetectFileOverlapRelation(newer, older *Observation) *RelationDetectionResult {
|
||||
// Check for overlapping modified files
|
||||
newerModified := make(map[string]bool)
|
||||
for _, f := range newer.FilesModified {
|
||||
newerModified[f] = true
|
||||
}
|
||||
|
||||
olderModified := make(map[string]bool)
|
||||
for _, f := range older.FilesModified {
|
||||
olderModified[f] = true
|
||||
}
|
||||
|
||||
// Files modified by both
|
||||
var sharedModified []string
|
||||
for f := range newerModified {
|
||||
if olderModified[f] {
|
||||
sharedModified = append(sharedModified, f)
|
||||
}
|
||||
}
|
||||
|
||||
// Files that newer reads which older modified
|
||||
var newerReadsOlderModified []string
|
||||
for _, f := range newer.FilesRead {
|
||||
if olderModified[f] {
|
||||
newerReadsOlderModified = append(newerReadsOlderModified, f)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate overlap score
|
||||
overlap := len(sharedModified) + len(newerReadsOlderModified)
|
||||
if overlap == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Determine relationship type based on observation types and file overlap
|
||||
relType := RelationRelatesTo
|
||||
confidence := 0.5 + float64(overlap)*0.1 // Base 0.5, +0.1 per overlapping file
|
||||
|
||||
// Type-based relationship inference
|
||||
switch {
|
||||
case newer.Type == ObsTypeBugfix && (older.Type == ObsTypeDecision || older.Type == ObsTypeFeature):
|
||||
relType = RelationFixes
|
||||
confidence += 0.2
|
||||
case newer.Type == ObsTypeRefactor && older.Type == ObsTypeDiscovery:
|
||||
relType = RelationEvolvesFrom
|
||||
confidence += 0.15
|
||||
case newer.Type == older.Type && len(sharedModified) > 0:
|
||||
relType = RelationSupersedes
|
||||
confidence += 0.1
|
||||
case newer.Type == ObsTypeFeature && older.Type == ObsTypeDecision:
|
||||
relType = RelationDependsOn
|
||||
confidence += 0.15
|
||||
}
|
||||
|
||||
if confidence > 1.0 {
|
||||
confidence = 1.0
|
||||
}
|
||||
|
||||
reason := buildFileOverlapReason(sharedModified, newerReadsOlderModified)
|
||||
|
||||
return &RelationDetectionResult{
|
||||
SourceID: newer.ID,
|
||||
TargetID: older.ID,
|
||||
RelationType: relType,
|
||||
Confidence: confidence,
|
||||
DetectionSource: DetectionSourceFileOverlap,
|
||||
Reason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
// buildFileOverlapReason creates a human-readable reason for file overlap relation.
|
||||
func buildFileOverlapReason(shared, readsModified []string) string {
|
||||
parts := []string{}
|
||||
if len(shared) > 0 {
|
||||
parts = append(parts, "both modified: "+strings.Join(truncateList(shared, 3), ", "))
|
||||
}
|
||||
if len(readsModified) > 0 {
|
||||
parts = append(parts, "reads files modified by older: "+strings.Join(truncateList(readsModified, 3), ", "))
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// DetectConceptOverlapRelation checks if observations share concepts.
|
||||
func DetectConceptOverlapRelation(newer, older *Observation) *RelationDetectionResult {
|
||||
newerConcepts := make(map[string]bool)
|
||||
for _, c := range newer.Concepts {
|
||||
newerConcepts[c] = true
|
||||
}
|
||||
|
||||
var shared []string
|
||||
for _, c := range older.Concepts {
|
||||
if newerConcepts[c] {
|
||||
shared = append(shared, c)
|
||||
}
|
||||
}
|
||||
|
||||
if len(shared) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Calculate confidence based on overlap ratio
|
||||
totalUniqueConcepts := len(newerConcepts)
|
||||
for _, c := range older.Concepts {
|
||||
if !newerConcepts[c] {
|
||||
totalUniqueConcepts++
|
||||
}
|
||||
}
|
||||
|
||||
overlapRatio := float64(len(shared)) / float64(totalUniqueConcepts)
|
||||
confidence := 0.3 + overlapRatio*0.5 // Base 0.3, scale with overlap
|
||||
|
||||
// Boost for important concepts
|
||||
for _, c := range shared {
|
||||
if isHighValueConcept(c) {
|
||||
confidence += 0.1
|
||||
}
|
||||
}
|
||||
if confidence > 1.0 {
|
||||
confidence = 1.0
|
||||
}
|
||||
|
||||
return &RelationDetectionResult{
|
||||
SourceID: newer.ID,
|
||||
TargetID: older.ID,
|
||||
RelationType: RelationRelatesTo,
|
||||
Confidence: confidence,
|
||||
DetectionSource: DetectionSourceConceptOverlap,
|
||||
Reason: "shared concepts: " + strings.Join(truncateList(shared, 5), ", "),
|
||||
}
|
||||
}
|
||||
|
||||
// isHighValueConcept returns true for concepts that strongly indicate relationships.
|
||||
func isHighValueConcept(concept string) bool {
|
||||
highValue := map[string]bool{
|
||||
"security": true,
|
||||
"architecture": true,
|
||||
"gotcha": true,
|
||||
"anti-pattern": true,
|
||||
"best-practice": true,
|
||||
"error-handling": true,
|
||||
}
|
||||
return highValue[concept]
|
||||
}
|
||||
|
||||
// DetectTypeProgressionRelation checks for natural type progressions.
|
||||
// Example: discovery -> decision -> feature -> bugfix
|
||||
func DetectTypeProgressionRelation(newer, older *Observation) *RelationDetectionResult {
|
||||
// Define natural type progressions
|
||||
progressions := map[ObservationType][]ObservationType{
|
||||
ObsTypeBugfix: {ObsTypeDiscovery, ObsTypeFeature, ObsTypeDecision},
|
||||
ObsTypeFeature: {ObsTypeDiscovery, ObsTypeDecision},
|
||||
ObsTypeRefactor: {ObsTypeDiscovery, ObsTypeFeature, ObsTypeBugfix},
|
||||
ObsTypeDecision: {ObsTypeDiscovery},
|
||||
ObsTypeChange: {ObsTypeDiscovery, ObsTypeDecision},
|
||||
}
|
||||
|
||||
validPredecessors, ok := progressions[newer.Type]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
isValidProgression := false
|
||||
for _, pred := range validPredecessors {
|
||||
if older.Type == pred {
|
||||
isValidProgression = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !isValidProgression {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Determine relationship type based on progression
|
||||
var relType RelationType
|
||||
var confidence float64 = 0.4
|
||||
|
||||
switch {
|
||||
case newer.Type == ObsTypeBugfix && older.Type == ObsTypeDiscovery:
|
||||
relType = RelationFixes
|
||||
confidence = 0.6
|
||||
case newer.Type == ObsTypeBugfix && older.Type == ObsTypeFeature:
|
||||
relType = RelationFixes
|
||||
confidence = 0.5
|
||||
case newer.Type == ObsTypeFeature && older.Type == ObsTypeDecision:
|
||||
relType = RelationDependsOn
|
||||
confidence = 0.6
|
||||
case newer.Type == ObsTypeRefactor:
|
||||
relType = RelationEvolvesFrom
|
||||
confidence = 0.5
|
||||
default:
|
||||
relType = RelationRelatesTo
|
||||
}
|
||||
|
||||
return &RelationDetectionResult{
|
||||
SourceID: newer.ID,
|
||||
TargetID: older.ID,
|
||||
RelationType: relType,
|
||||
Confidence: confidence,
|
||||
DetectionSource: DetectionSourceTypeProgression,
|
||||
Reason: string(older.Type) + " -> " + string(newer.Type) + " progression",
|
||||
}
|
||||
}
|
||||
|
||||
// DetectTemporalProximityRelation checks if observations are temporally close (same session).
|
||||
func DetectTemporalProximityRelation(newer, older *Observation) *RelationDetectionResult {
|
||||
// Only relate observations from the same session
|
||||
if newer.SDKSessionID != older.SDKSessionID {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check temporal proximity (within 5 minutes)
|
||||
timeDiffMs := newer.CreatedAtEpoch - older.CreatedAtEpoch
|
||||
if timeDiffMs < 0 {
|
||||
timeDiffMs = -timeDiffMs
|
||||
}
|
||||
|
||||
fiveMinutesMs := int64(5 * 60 * 1000)
|
||||
if timeDiffMs > fiveMinutesMs {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Calculate confidence based on temporal proximity
|
||||
// Closer = higher confidence
|
||||
proximityRatio := 1.0 - (float64(timeDiffMs) / float64(fiveMinutesMs))
|
||||
confidence := 0.3 + proximityRatio*0.4
|
||||
|
||||
return &RelationDetectionResult{
|
||||
SourceID: newer.ID,
|
||||
TargetID: older.ID,
|
||||
RelationType: RelationRelatesTo,
|
||||
Confidence: confidence,
|
||||
DetectionSource: DetectionSourceTemporalProximity,
|
||||
Reason: "same session, close timestamps",
|
||||
}
|
||||
}
|
||||
|
||||
// NarrativeMentionPatterns are patterns that indicate explicit relationships in narratives.
|
||||
var NarrativeMentionPatterns = []struct {
|
||||
Pattern string
|
||||
RelationType RelationType
|
||||
ConfBoost float64
|
||||
}{
|
||||
{" caused ", RelationCauses, 0.3},
|
||||
{" causes ", RelationCauses, 0.3},
|
||||
{" because of ", RelationCauses, 0.25},
|
||||
{" due to ", RelationCauses, 0.2},
|
||||
{" fixes ", RelationFixes, 0.3},
|
||||
{" fixed ", RelationFixes, 0.3},
|
||||
{" resolves ", RelationFixes, 0.3},
|
||||
{" addresses ", RelationFixes, 0.25},
|
||||
{" replaces ", RelationSupersedes, 0.3},
|
||||
{" supersedes ", RelationSupersedes, 0.35},
|
||||
{" instead of ", RelationSupersedes, 0.25},
|
||||
{" depends on ", RelationDependsOn, 0.3},
|
||||
{" requires ", RelationDependsOn, 0.25},
|
||||
{" builds on ", RelationDependsOn, 0.25},
|
||||
{" based on ", RelationDependsOn, 0.2},
|
||||
{" related to ", RelationRelatesTo, 0.2},
|
||||
{" similar to ", RelationRelatesTo, 0.2},
|
||||
{" evolved from ", RelationEvolvesFrom, 0.3},
|
||||
{" improved from ", RelationEvolvesFrom, 0.25},
|
||||
{" refined from ", RelationEvolvesFrom, 0.25},
|
||||
}
|
||||
|
||||
// DetectNarrativeMentionRelation checks if newer observation's narrative mentions relationship.
|
||||
func DetectNarrativeMentionRelation(newer, older *Observation) *RelationDetectionResult {
|
||||
if !newer.Narrative.Valid || newer.Narrative.String == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
narrative := strings.ToLower(newer.Narrative.String)
|
||||
|
||||
// Check for patterns
|
||||
for _, p := range NarrativeMentionPatterns {
|
||||
if strings.Contains(narrative, p.Pattern) {
|
||||
// Found a pattern - this is a potential relationship
|
||||
confidence := 0.4 + p.ConfBoost
|
||||
if confidence > 1.0 {
|
||||
confidence = 1.0
|
||||
}
|
||||
|
||||
return &RelationDetectionResult{
|
||||
SourceID: newer.ID,
|
||||
TargetID: older.ID,
|
||||
RelationType: p.RelationType,
|
||||
Confidence: confidence,
|
||||
DetectionSource: DetectionSourceNarrativeMention,
|
||||
Reason: "narrative contains '" + strings.TrimSpace(p.Pattern) + "' language",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DetectRelationsWithExisting checks a new observation against existing ones and returns detected relations.
|
||||
// This is the main entry point for relation detection.
|
||||
func DetectRelationsWithExisting(newer *Observation, existing []*Observation, minConfidence float64) []*RelationDetectionResult {
|
||||
var results []*RelationDetectionResult
|
||||
seen := make(map[int64]bool)
|
||||
|
||||
for _, older := range existing {
|
||||
// Skip self
|
||||
if older.ID == newer.ID {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if already superseded
|
||||
if older.IsSuperseded {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only compare within same project (or both global)
|
||||
if newer.Project != older.Project && newer.Scope != ScopeGlobal && older.Scope != ScopeGlobal {
|
||||
continue
|
||||
}
|
||||
|
||||
// Run all detection methods and keep highest confidence result per target
|
||||
var bestResult *RelationDetectionResult
|
||||
|
||||
// 1. File overlap detection
|
||||
if result := DetectFileOverlapRelation(newer, older); result != nil && result.Confidence >= minConfidence {
|
||||
if bestResult == nil || result.Confidence > bestResult.Confidence {
|
||||
bestResult = result
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Concept overlap detection
|
||||
if result := DetectConceptOverlapRelation(newer, older); result != nil && result.Confidence >= minConfidence {
|
||||
if bestResult == nil || result.Confidence > bestResult.Confidence {
|
||||
bestResult = result
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Type progression detection
|
||||
if result := DetectTypeProgressionRelation(newer, older); result != nil && result.Confidence >= minConfidence {
|
||||
if bestResult == nil || result.Confidence > bestResult.Confidence {
|
||||
bestResult = result
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Temporal proximity detection
|
||||
if result := DetectTemporalProximityRelation(newer, older); result != nil && result.Confidence >= minConfidence {
|
||||
// Only use temporal proximity if no better detection found
|
||||
if bestResult == nil {
|
||||
bestResult = result
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Narrative mention detection (can upgrade relation type)
|
||||
if result := DetectNarrativeMentionRelation(newer, older); result != nil && result.Confidence >= minConfidence {
|
||||
if bestResult == nil || result.Confidence > bestResult.Confidence {
|
||||
bestResult = result
|
||||
}
|
||||
}
|
||||
|
||||
// Add best result if found and not already seen
|
||||
if bestResult != nil && !seen[older.ID] {
|
||||
results = append(results, bestResult)
|
||||
seen[older.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// truncateList truncates a list to maxLen items.
|
||||
func truncateList(items []string, maxLen int) []string {
|
||||
if len(items) <= maxLen {
|
||||
return items
|
||||
}
|
||||
result := items[:maxLen]
|
||||
return append(result, "...")
|
||||
}
|
||||
|
||||
// RelationWithDetails contains a relation with its observation details.
|
||||
type RelationWithDetails struct {
|
||||
Relation *ObservationRelation `json:"relation"`
|
||||
SourceTitle string `json:"source_title"`
|
||||
TargetTitle string `json:"target_title"`
|
||||
SourceType ObservationType `json:"source_type"`
|
||||
TargetType ObservationType `json:"target_type"`
|
||||
}
|
||||
|
||||
// RelationGraph represents a graph of related observations.
|
||||
type RelationGraph struct {
|
||||
CenterID int64 `json:"center_id"`
|
||||
Relations []*RelationWithDetails `json:"relations"`
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
// Package models contains domain models for claude-mnemonic.
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetectFileOverlapRelation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
newer *Observation
|
||||
older *Observation
|
||||
wantRelation bool
|
||||
wantRelType RelationType
|
||||
wantMinConfid float64
|
||||
}{
|
||||
{
|
||||
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 {
|
||||
name string
|
||||
newer *Observation
|
||||
older *Observation
|
||||
wantRelation bool
|
||||
wantMinConfid float64
|
||||
}{
|
||||
{
|
||||
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
|
||||
wantRelation bool
|
||||
wantRelType RelationType
|
||||
}{
|
||||
{
|
||||
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
|
||||
wantRelation bool
|
||||
wantRelType RelationType
|
||||
}{
|
||||
{
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Package models contains domain models for claude-mnemonic.
|
||||
package models
|
||||
|
||||
// ConceptWeight represents a configurable weight for a concept.
|
||||
type ConceptWeight struct {
|
||||
Concept string `db:"concept" json:"concept"`
|
||||
Weight float64 `db:"weight" json:"weight"`
|
||||
UpdatedAt string `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// UserFeedbackType represents the type of user feedback.
|
||||
type UserFeedbackType int
|
||||
|
||||
const (
|
||||
// FeedbackNegative represents a thumbs down.
|
||||
FeedbackNegative UserFeedbackType = -1
|
||||
// FeedbackNeutral represents no feedback.
|
||||
FeedbackNeutral UserFeedbackType = 0
|
||||
// FeedbackPositive represents a thumbs up.
|
||||
FeedbackPositive UserFeedbackType = 1
|
||||
)
|
||||
|
||||
// DefaultConceptWeights contains the default weights for concepts.
|
||||
// Higher weights indicate more important concepts.
|
||||
var DefaultConceptWeights = map[string]float64{
|
||||
// Critical concepts (0.25-0.30)
|
||||
"security": 0.30, // Security issues are most critical
|
||||
|
||||
// High importance (0.20-0.25)
|
||||
"gotcha": 0.25, // Gotchas prevent future mistakes
|
||||
"best-practice": 0.20, // Best practices guide development
|
||||
"anti-pattern": 0.20, // Anti-patterns prevent bad code
|
||||
|
||||
// Medium importance (0.10-0.15)
|
||||
"architecture": 0.15, // Architectural decisions have lasting impact
|
||||
"performance": 0.15, // Performance patterns matter for scale
|
||||
"error-handling": 0.15, // Error handling prevents failures
|
||||
"pattern": 0.10, // General patterns are useful
|
||||
"testing": 0.10, // Testing knowledge helps quality
|
||||
"debugging": 0.10, // Debugging tips save time
|
||||
"problem-solution": 0.10, // Problem-solution pairs are actionable
|
||||
"trade-off": 0.10, // Trade-offs inform decisions
|
||||
|
||||
// Lower importance (0.05)
|
||||
"workflow": 0.05, // Workflow optimizations are nice-to-have
|
||||
"tooling": 0.05, // Tooling preferences are subjective
|
||||
"how-it-works": 0.05, // Understanding is foundational but less urgent
|
||||
"why-it-exists": 0.05, // Context is helpful but less actionable
|
||||
"what-changed": 0.05, // Changes are informational
|
||||
}
|
||||
|
||||
// TypeBaseScores contains the base importance multipliers for each observation type.
|
||||
// These are multiplied with the core score to weight different observation types.
|
||||
var TypeBaseScores = map[ObservationType]float64{
|
||||
ObsTypeBugfix: 1.3, // Bugfixes are valuable - prevent regressions
|
||||
ObsTypeFeature: 1.2, // New features expand capabilities
|
||||
ObsTypeDiscovery: 1.1, // Discoveries inform future work
|
||||
ObsTypeDecision: 1.1, // Architectural decisions guide development
|
||||
ObsTypeRefactor: 1.0, // Refactoring is neutral
|
||||
ObsTypeChange: 0.9, // Minor changes are slightly less important
|
||||
}
|
||||
|
||||
// ScoringConfig contains all scoring weights and parameters.
|
||||
type ScoringConfig struct {
|
||||
// RecencyHalfLifeDays is the number of days for the importance score to halve.
|
||||
// With 7 days, a 7-day old observation has 50% of a new observation's recency score.
|
||||
RecencyHalfLifeDays float64 `json:"recency_half_life_days"`
|
||||
|
||||
// FeedbackWeight scales the user feedback contribution to final score.
|
||||
// With 0.30, a thumbs up adds 0.30 to the score, thumbs down subtracts 0.30.
|
||||
FeedbackWeight float64 `json:"feedback_weight"`
|
||||
|
||||
// ConceptWeight scales the concept boost contribution.
|
||||
// The sum of matching concept weights is multiplied by this.
|
||||
ConceptWeight float64 `json:"concept_weight"`
|
||||
|
||||
// RetrievalWeight scales the retrieval boost contribution.
|
||||
// Popular observations get a logarithmic bonus.
|
||||
RetrievalWeight float64 `json:"retrieval_weight"`
|
||||
|
||||
// ConceptWeights maps concept names to their importance weights.
|
||||
ConceptWeights map[string]float64 `json:"concept_weights"`
|
||||
|
||||
// MinScore is the minimum allowed importance score.
|
||||
// Prevents observations from completely disappearing.
|
||||
MinScore float64 `json:"min_score"`
|
||||
}
|
||||
|
||||
// DefaultScoringConfig returns the default scoring configuration.
|
||||
func DefaultScoringConfig() *ScoringConfig {
|
||||
conceptWeights := make(map[string]float64, len(DefaultConceptWeights))
|
||||
for k, v := range DefaultConceptWeights {
|
||||
conceptWeights[k] = v
|
||||
}
|
||||
|
||||
return &ScoringConfig{
|
||||
RecencyHalfLifeDays: 7.0, // Score halves every 7 days
|
||||
FeedbackWeight: 0.30, // Feedback has moderate impact
|
||||
ConceptWeight: 0.20, // Concept weights have smaller impact
|
||||
RetrievalWeight: 0.15, // Retrieval has smallest impact
|
||||
ConceptWeights: conceptWeights,
|
||||
MinScore: 0.01, // Never completely disappear
|
||||
}
|
||||
}
|
||||
|
||||
// TypeBaseScore returns the base weight for an observation type.
|
||||
func TypeBaseScore(t ObservationType) float64 {
|
||||
if score, ok := TypeBaseScores[t]; ok {
|
||||
return score
|
||||
}
|
||||
return 1.0 // Default for unknown types
|
||||
}
|
||||
Reference in New Issue
Block a user