// 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 { ResolvedAt *string `db:"resolved_at" json:"resolved_at,omitempty"` 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"` 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"` DetectedAtEpoch int64 `db:"detected_at_epoch" json:"detected_at_epoch"` Resolved bool `db:"resolved" json:"resolved"` } // ConflictDetectionResult contains the result of conflict detection. type ConflictDetectionResult struct { Type ConflictType Resolution ConflictResolution Reason string OlderObsIDs []int64 HasConflict bool } // 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 }