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:
2025-12-19 17:57:11 +00:00
committed by GitHub
parent 48957a6c81
commit f79782a008
69 changed files with 43967 additions and 194 deletions
+258
View File
@@ -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
}
+421
View File
@@ -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)
}
+51
View File
@@ -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 {
+398
View File
@@ -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)
}
+357
View File
@@ -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)
}
}
}
+489
View File
@@ -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"`
}
+473
View File
@@ -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")
}
}
+112
View File
@@ -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
}