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
+276
View File
@@ -0,0 +1,276 @@
// Package sqlite provides SQLite database operations for claude-mnemonic.
package sqlite
import (
"context"
"time"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
)
// SupersededRetentionDays is the number of days to keep superseded observations before deletion.
const SupersededRetentionDays = 3
// ConflictStore provides conflict-related database operations.
type ConflictStore struct {
store *Store
}
// NewConflictStore creates a new conflict store.
func NewConflictStore(store *Store) *ConflictStore {
return &ConflictStore{store: store}
}
// StoreConflict stores a new observation conflict.
func (s *ConflictStore) StoreConflict(ctx context.Context, conflict *models.ObservationConflict) (int64, error) {
const query = `
INSERT INTO observation_conflicts
(newer_obs_id, older_obs_id, conflict_type, resolution, reason, detected_at, detected_at_epoch, resolved, resolved_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`
result, err := s.store.ExecContext(ctx, query,
conflict.NewerObsID, conflict.OlderObsID,
string(conflict.ConflictType), string(conflict.Resolution),
conflict.Reason, conflict.DetectedAt, conflict.DetectedAtEpoch,
conflict.Resolved, conflict.ResolvedAt,
)
if err != nil {
return 0, err
}
return result.LastInsertId()
}
// MarkObservationSuperseded marks an observation as superseded.
func (s *ConflictStore) MarkObservationSuperseded(ctx context.Context, obsID int64) error {
const query = `UPDATE observations SET is_superseded = 1 WHERE id = ?`
_, err := s.store.ExecContext(ctx, query, obsID)
return err
}
// MarkObservationsSuperseded marks multiple observations as superseded.
func (s *ConflictStore) MarkObservationsSuperseded(ctx context.Context, obsIDs []int64) error {
if len(obsIDs) == 0 {
return nil
}
query := `UPDATE observations SET is_superseded = 1 WHERE id IN (?` + repeatPlaceholders(len(obsIDs)-1) + `)` // #nosec G202 -- uses parameterized placeholders
args := int64SliceToInterface(obsIDs)
_, err := s.store.db.ExecContext(ctx, query, args...)
return err
}
// GetConflictsByObservationID retrieves all conflicts involving an observation.
func (s *ConflictStore) GetConflictsByObservationID(ctx context.Context, obsID int64) ([]*models.ObservationConflict, error) {
const query = `
SELECT id, newer_obs_id, older_obs_id, conflict_type, resolution, reason,
detected_at, detected_at_epoch, resolved, resolved_at
FROM observation_conflicts
WHERE newer_obs_id = ? OR older_obs_id = ?
ORDER BY detected_at_epoch DESC
`
rows, err := s.store.QueryContext(ctx, query, obsID, obsID)
if err != nil {
return nil, err
}
defer rows.Close()
return s.scanConflictRows(rows)
}
// GetUnresolvedConflicts retrieves all unresolved conflicts.
func (s *ConflictStore) GetUnresolvedConflicts(ctx context.Context, limit int) ([]*models.ObservationConflict, error) {
const query = `
SELECT id, newer_obs_id, older_obs_id, conflict_type, resolution, reason,
detected_at, detected_at_epoch, resolved, resolved_at
FROM observation_conflicts
WHERE resolved = 0
ORDER BY detected_at_epoch DESC
LIMIT ?
`
rows, err := s.store.QueryContext(ctx, query, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return s.scanConflictRows(rows)
}
// GetSupersededObservationIDs returns IDs of all observations that have been superseded.
func (s *ConflictStore) GetSupersededObservationIDs(ctx context.Context, project string) ([]int64, error) {
const query = `
SELECT DISTINCT older_obs_id
FROM observation_conflicts oc
JOIN observations o ON o.id = oc.older_obs_id
WHERE oc.resolution = 'prefer_newer'
AND (o.project = ? OR o.scope = 'global')
`
rows, err := s.store.QueryContext(ctx, query, project)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// ResolveConflict marks a conflict as resolved.
func (s *ConflictStore) ResolveConflict(ctx context.Context, conflictID int64, resolution models.ConflictResolution) error {
now := time.Now().Format(time.RFC3339)
const query = `
UPDATE observation_conflicts
SET resolved = 1, resolved_at = ?, resolution = ?
WHERE id = ?
`
_, err := s.store.ExecContext(ctx, query, now, string(resolution), conflictID)
return err
}
// DeleteConflictsByObservationID deletes all conflicts involving an observation.
// Called when an observation is deleted.
func (s *ConflictStore) DeleteConflictsByObservationID(ctx context.Context, obsID int64) error {
const query = `DELETE FROM observation_conflicts WHERE newer_obs_id = ? OR older_obs_id = ?`
_, err := s.store.ExecContext(ctx, query, obsID, obsID)
return err
}
// ConflictWithDetails contains a conflict with its observation details.
type ConflictWithDetails struct {
Conflict *models.ObservationConflict
NewerObsTitle string
OlderObsTitle string
}
// CleanupSupersededObservations deletes observations that have been superseded for longer than
// SupersededRetentionDays. Returns the IDs of deleted observations for downstream cleanup (e.g., vector DB).
func (s *ConflictStore) CleanupSupersededObservations(ctx context.Context, project string) ([]int64, error) {
// Calculate cutoff time (3 days ago in milliseconds)
cutoffEpoch := time.Now().AddDate(0, 0, -SupersededRetentionDays).UnixMilli()
// First, find the IDs that will be deleted
// We delete observations that:
// 1. Are marked as superseded
// 2. Have a conflict record where they are the older observation
// 3. The conflict was detected more than 3 days ago
const selectQuery = `
SELECT DISTINCT o.id FROM observations o
JOIN observation_conflicts oc ON o.id = oc.older_obs_id
WHERE o.is_superseded = 1
AND o.project = ?
AND oc.detected_at_epoch < ?
`
rows, err := s.store.QueryContext(ctx, selectQuery, project, cutoffEpoch)
if err != nil {
return nil, err
}
defer rows.Close()
var toDelete []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
toDelete = append(toDelete, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(toDelete) == 0 {
return nil, nil
}
// Delete the conflict records first (due to foreign key constraints)
for _, obsID := range toDelete {
if err := s.DeleteConflictsByObservationID(ctx, obsID); err != nil {
return nil, err
}
}
// Delete the observations
deleteQuery := `DELETE FROM observations WHERE id IN (?` + repeatPlaceholders(len(toDelete)-1) + `)` // #nosec G202 -- uses parameterized placeholders
args := int64SliceToInterface(toDelete)
_, err = s.store.db.ExecContext(ctx, deleteQuery, args...)
if err != nil {
return nil, err
}
return toDelete, nil
}
// GetConflictsWithDetails retrieves all conflicts with observation titles for display.
func (s *ConflictStore) GetConflictsWithDetails(ctx context.Context, project string, limit int) ([]*ConflictWithDetails, error) {
const query = `
SELECT oc.id, oc.newer_obs_id, oc.older_obs_id, oc.conflict_type, oc.resolution, oc.reason,
oc.detected_at, oc.detected_at_epoch, oc.resolved, oc.resolved_at,
COALESCE(newer.title, '') as newer_title,
COALESCE(older.title, '') as older_title
FROM observation_conflicts oc
JOIN observations newer ON newer.id = oc.newer_obs_id
JOIN observations older ON older.id = oc.older_obs_id
WHERE newer.project = ? OR older.project = ?
ORDER BY oc.detected_at_epoch DESC
LIMIT ?
`
rows, err := s.store.QueryContext(ctx, query, project, project, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var results []*ConflictWithDetails
for rows.Next() {
var c models.ObservationConflict
var cwd ConflictWithDetails
if err := rows.Scan(
&c.ID, &c.NewerObsID, &c.OlderObsID,
&c.ConflictType, &c.Resolution, &c.Reason,
&c.DetectedAt, &c.DetectedAtEpoch,
&c.Resolved, &c.ResolvedAt,
&cwd.NewerObsTitle, &cwd.OlderObsTitle,
); err != nil {
return nil, err
}
cwd.Conflict = &c
results = append(results, &cwd)
}
return results, rows.Err()
}
// scanConflictRows scans multiple conflicts from rows.
func (s *ConflictStore) scanConflictRows(rows interface {
Next() bool
Scan(...interface{}) error
Err() error
}) ([]*models.ObservationConflict, error) {
var conflicts []*models.ObservationConflict
for rows.Next() {
var c models.ObservationConflict
if err := rows.Scan(
&c.ID, &c.NewerObsID, &c.OlderObsID,
&c.ConflictType, &c.Resolution, &c.Reason,
&c.DetectedAt, &c.DetectedAtEpoch,
&c.Resolved, &c.ResolvedAt,
); err != nil {
return nil, err
}
conflicts = append(conflicts, &c)
}
return conflicts, rows.Err()
}
+207
View File
@@ -283,6 +283,213 @@ var Migrations = []Migration{
ON user_prompts(claude_session_id, prompt_number);
`,
},
{
Version: 19,
Name: "vectors_with_model_version",
SQL: `
-- Drop old vectors table (virtual tables cannot be altered)
DROP TABLE IF EXISTS vectors;
-- Recreate vectors table with model_version column
-- Uses bge-small-en-v1.5 embeddings (384 dimensions)
CREATE VIRTUAL TABLE IF NOT EXISTS vectors USING vec0(
doc_id TEXT PRIMARY KEY,
embedding float[384],
sqlite_id INTEGER,
doc_type TEXT,
field_type TEXT,
project TEXT,
scope TEXT,
model_version TEXT
);
`,
},
{
Version: 20,
Name: "importance_scoring",
SQL: `
-- Importance scoring system for observations
-- Implements multi-factor scoring: type weight, recency decay, user feedback, concept weights, retrieval boost
-- Cached importance score (recalculated periodically)
ALTER TABLE observations ADD COLUMN importance_score REAL DEFAULT 1.0;
-- User feedback: -1 = thumbs down, 0 = neutral, 1 = thumbs up
ALTER TABLE observations ADD COLUMN user_feedback INTEGER DEFAULT 0;
-- Retrieval tracking: how many times this observation was returned in searches
ALTER TABLE observations ADD COLUMN retrieval_count INTEGER DEFAULT 0;
-- Last time this observation was retrieved (for analytics)
ALTER TABLE observations ADD COLUMN last_retrieved_at_epoch INTEGER;
-- Timestamp of last score recalculation
ALTER TABLE observations ADD COLUMN score_updated_at_epoch INTEGER;
-- Index for importance-based sorting (primary ordering strategy)
CREATE INDEX IF NOT EXISTS idx_observations_importance
ON observations(importance_score DESC, created_at_epoch DESC);
-- Index for finding observations needing score recalculation
CREATE INDEX IF NOT EXISTS idx_observations_score_updated
ON observations(score_updated_at_epoch);
-- Configurable concept weights table
-- Allows runtime tuning of how much each concept contributes to importance
CREATE TABLE IF NOT EXISTS concept_weights (
concept TEXT PRIMARY KEY,
weight REAL NOT NULL DEFAULT 0.1,
updated_at TEXT NOT NULL
);
-- Seed default concept weights (security highest, tooling lowest)
INSERT OR IGNORE INTO concept_weights (concept, weight, updated_at) VALUES
('security', 0.30, datetime('now')),
('gotcha', 0.25, datetime('now')),
('best-practice', 0.20, datetime('now')),
('anti-pattern', 0.20, datetime('now')),
('architecture', 0.15, datetime('now')),
('performance', 0.15, datetime('now')),
('error-handling', 0.15, datetime('now')),
('pattern', 0.10, datetime('now')),
('testing', 0.10, datetime('now')),
('debugging', 0.10, datetime('now')),
('workflow', 0.05, datetime('now')),
('tooling', 0.05, datetime('now'));
`,
},
{
Version: 21,
Name: "observation_conflicts",
SQL: `
-- Observation conflicts table for tracking contradictions and superseded observations
-- Implements Issue #5: Contradiction & Obsolescence Detection
CREATE TABLE IF NOT EXISTS observation_conflicts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
newer_obs_id INTEGER NOT NULL,
older_obs_id INTEGER NOT NULL,
conflict_type TEXT NOT NULL CHECK(conflict_type IN ('superseded', 'contradicts', 'outdated_pattern')),
resolution TEXT NOT NULL CHECK(resolution IN ('prefer_newer', 'prefer_older', 'manual')),
reason TEXT,
detected_at TEXT NOT NULL,
detected_at_epoch INTEGER NOT NULL,
resolved INTEGER DEFAULT 0,
resolved_at TEXT,
FOREIGN KEY(newer_obs_id) REFERENCES observations(id) ON DELETE CASCADE,
FOREIGN KEY(older_obs_id) REFERENCES observations(id) ON DELETE CASCADE
);
-- Index for looking up conflicts by observation ID
CREATE INDEX IF NOT EXISTS idx_conflicts_newer ON observation_conflicts(newer_obs_id);
CREATE INDEX IF NOT EXISTS idx_conflicts_older ON observation_conflicts(older_obs_id);
CREATE INDEX IF NOT EXISTS idx_conflicts_unresolved ON observation_conflicts(resolved, detected_at_epoch DESC);
-- Add is_superseded column to observations for quick filtering
-- Set to 1 when this observation has been superseded by a newer one
ALTER TABLE observations ADD COLUMN is_superseded INTEGER DEFAULT 0;
-- Index for filtering out superseded observations in queries
CREATE INDEX IF NOT EXISTS idx_observations_superseded ON observations(is_superseded, importance_score DESC);
`,
},
{
Version: 22,
Name: "patterns_table",
SQL: `
-- Pattern Recognition Engine (Issue #7)
-- Tracks recurring patterns detected across observations
-- Enables Claude to reference historical insights: "I've encountered this pattern 12 times."
CREATE TABLE IF NOT EXISTS patterns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('bug', 'refactor', 'architecture', 'anti-pattern', 'best-practice')),
description TEXT,
signature TEXT, -- JSON array of keywords/concepts for detection
recommendation TEXT, -- What works for this pattern
frequency INTEGER DEFAULT 1, -- How many times encountered
projects TEXT, -- JSON array of projects where seen
observation_ids TEXT, -- JSON array of source observation IDs
status TEXT DEFAULT 'active' CHECK(status IN ('active', 'deprecated', 'merged')),
merged_into_id INTEGER, -- If status is 'merged', which pattern it merged into
confidence REAL DEFAULT 0.5, -- Detection confidence (0.0-1.0)
last_seen_at TEXT NOT NULL,
last_seen_at_epoch INTEGER NOT NULL,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
FOREIGN KEY(merged_into_id) REFERENCES patterns(id) ON DELETE SET NULL
);
-- Indexes for efficient pattern queries
CREATE INDEX IF NOT EXISTS idx_patterns_type ON patterns(type);
CREATE INDEX IF NOT EXISTS idx_patterns_status ON patterns(status);
CREATE INDEX IF NOT EXISTS idx_patterns_frequency ON patterns(frequency DESC);
CREATE INDEX IF NOT EXISTS idx_patterns_confidence ON patterns(confidence DESC);
CREATE INDEX IF NOT EXISTS idx_patterns_last_seen ON patterns(last_seen_at_epoch DESC);
-- FTS5 virtual table for pattern search
CREATE VIRTUAL TABLE IF NOT EXISTS patterns_fts USING fts5(
name, description, recommendation,
content='patterns',
content_rowid='id'
);
-- Triggers for FTS5 sync
CREATE TRIGGER IF NOT EXISTS patterns_ai AFTER INSERT ON patterns BEGIN
INSERT INTO patterns_fts(rowid, name, description, recommendation)
VALUES (new.id, new.name, new.description, new.recommendation);
END;
CREATE TRIGGER IF NOT EXISTS patterns_ad AFTER DELETE ON patterns BEGIN
INSERT INTO patterns_fts(patterns_fts, rowid, name, description, recommendation)
VALUES('delete', old.id, old.name, old.description, old.recommendation);
END;
CREATE TRIGGER IF NOT EXISTS patterns_au AFTER UPDATE ON patterns BEGIN
INSERT INTO patterns_fts(patterns_fts, rowid, name, description, recommendation)
VALUES('delete', old.id, old.name, old.description, old.recommendation);
INSERT INTO patterns_fts(rowid, name, description, recommendation)
VALUES (new.id, new.name, new.description, new.recommendation);
END;
`,
},
{
Version: 23,
Name: "observation_relations",
SQL: `
-- Knowledge Graph: Observation Relations (Issue #4)
-- Tracks explicit relationships between observations for knowledge graph navigation.
-- Enables queries like "What caused this bug?" or "What depends on this decision?"
CREATE TABLE IF NOT EXISTS observation_relations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER NOT NULL,
target_id INTEGER NOT NULL,
relation_type TEXT NOT NULL CHECK(relation_type IN ('causes', 'fixes', 'supersedes', 'depends_on', 'relates_to', 'evolves_from')),
confidence REAL NOT NULL DEFAULT 0.5,
detection_source TEXT NOT NULL CHECK(detection_source IN ('file_overlap', 'embedding_similarity', 'temporal_proximity', 'narrative_mention', 'concept_overlap', 'type_progression')),
reason TEXT,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
FOREIGN KEY(source_id) REFERENCES observations(id) ON DELETE CASCADE,
FOREIGN KEY(target_id) REFERENCES observations(id) ON DELETE CASCADE,
UNIQUE(source_id, target_id, relation_type)
);
-- Index for finding relations by source observation
CREATE INDEX IF NOT EXISTS idx_relations_source ON observation_relations(source_id);
-- Index for finding relations by target observation
CREATE INDEX IF NOT EXISTS idx_relations_target ON observation_relations(target_id);
-- Index for relation type queries
CREATE INDEX IF NOT EXISTS idx_relations_type ON observation_relations(relation_type);
-- Index for confidence-based filtering
CREATE INDEX IF NOT EXISTS idx_relations_confidence ON observation_relations(confidence DESC);
-- Index for finding all relations involving an observation (either direction)
CREATE INDEX IF NOT EXISTS idx_relations_both ON observation_relations(source_id, target_id);
`,
},
}
// MigrationManager handles database schema migrations.
+229 -40
View File
@@ -11,14 +11,27 @@ import (
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
)
// observationColumns is the standard list of columns to select for observations.
// This ensures consistency across all observation queries and includes importance scoring fields.
const observationColumns = `id, sdk_session_id, project, COALESCE(scope, 'project') as scope, type,
title, subtitle, facts, narrative, concepts, files_read, files_modified, file_mtimes,
prompt_number, discovery_tokens, created_at, created_at_epoch,
COALESCE(importance_score, 1.0) as importance_score,
COALESCE(user_feedback, 0) as user_feedback,
COALESCE(retrieval_count, 0) as retrieval_count,
last_retrieved_at_epoch, score_updated_at_epoch,
COALESCE(is_superseded, 0) as is_superseded`
// CleanupFunc is a callback for when observations are cleaned up.
// Receives the IDs of deleted observations for downstream cleanup (e.g., vector DB).
type CleanupFunc func(ctx context.Context, deletedIDs []int64)
// ObservationStore provides observation-related database operations.
type ObservationStore struct {
store *Store
cleanupFunc CleanupFunc
store *Store
cleanupFunc CleanupFunc
conflictStore *ConflictStore
relationStore *RelationStore
}
// NewObservationStore creates a new observation store.
@@ -31,6 +44,16 @@ func (s *ObservationStore) SetCleanupFunc(fn CleanupFunc) {
s.cleanupFunc = fn
}
// SetConflictStore sets the conflict store for conflict detection.
func (s *ObservationStore) SetConflictStore(conflictStore *ConflictStore) {
s.conflictStore = conflictStore
}
// SetRelationStore sets the relation store for relationship detection.
func (s *ObservationStore) SetRelationStore(relationStore *RelationStore) {
s.relationStore = relationStore
}
// StoreObservation stores a new observation.
func (s *ObservationStore) StoreObservation(ctx context.Context, sdkSessionID, project string, obs *models.ParsedObservation, promptNumber int, discoveryTokens int64) (int64, int64, error) {
now := time.Now()
@@ -86,9 +109,112 @@ func (s *ObservationStore) StoreObservation(ctx context.Context, sdkSessionID, p
}(project)
}
// Detect conflicts with existing observations (async to not block handler)
if s.conflictStore != nil && project != "" {
go func(newObsID int64, proj string, parsedObs *models.ParsedObservation) {
conflictCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
s.detectAndStoreConflicts(conflictCtx, newObsID, proj, parsedObs)
}(id, project, obs)
}
// Detect relationships with existing observations (async to not block handler)
if s.relationStore != nil && project != "" {
go func(newObsID int64, proj string) {
relationCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
s.detectAndStoreRelations(relationCtx, newObsID, proj)
}(id, project)
}
return id, nowEpoch, nil
}
// detectAndStoreConflicts detects conflicts between a new observation and existing ones.
func (s *ObservationStore) detectAndStoreConflicts(ctx context.Context, newObsID int64, project string, parsedObs *models.ParsedObservation) {
// Fetch the newly stored observation
newObs, err := s.GetObservationByID(ctx, newObsID)
if err != nil || newObs == nil {
return
}
// Fetch recent observations from the same project to check for conflicts
existing, err := s.GetRecentObservations(ctx, project, 50)
if err != nil {
return
}
// Detect conflicts
conflicts := models.DetectConflictsWithExisting(newObs, existing)
// Store conflicts and mark superseded observations
var supersededIDs []int64
for _, result := range conflicts {
for _, olderID := range result.OlderObsIDs {
conflict := models.NewObservationConflict(
newObsID, olderID,
result.Type, result.Resolution, result.Reason,
)
if _, err := s.conflictStore.StoreConflict(ctx, conflict); err != nil {
continue
}
// If resolution is to prefer newer, mark older as superseded
if result.Resolution == models.ResolutionPreferNewer {
supersededIDs = append(supersededIDs, olderID)
}
}
}
// Mark superseded observations
if len(supersededIDs) > 0 {
_ = s.conflictStore.MarkObservationsSuperseded(ctx, supersededIDs)
}
// Cleanup old superseded observations (older than 3 days)
deletedIDs, _ := s.conflictStore.CleanupSupersededObservations(ctx, project)
if len(deletedIDs) > 0 && s.cleanupFunc != nil {
s.cleanupFunc(ctx, deletedIDs)
}
}
// MinRelationConfidence is the minimum confidence threshold for storing relations.
const MinRelationConfidence = 0.4
// detectAndStoreRelations detects relationships between a new observation and existing ones.
func (s *ObservationStore) detectAndStoreRelations(ctx context.Context, newObsID int64, project string) {
// Fetch the newly stored observation
newObs, err := s.GetObservationByID(ctx, newObsID)
if err != nil || newObs == nil {
return
}
// Fetch recent observations from the same project to check for relations
existing, err := s.GetRecentObservations(ctx, project, 50)
if err != nil {
return
}
// Detect relationships using the models package detection logic
results := models.DetectRelationsWithExisting(newObs, existing, MinRelationConfidence)
if len(results) == 0 {
return
}
// Convert detection results to relation objects
relations := make([]*models.ObservationRelation, len(results))
for i, r := range results {
relations[i] = models.NewObservationRelation(
r.SourceID, r.TargetID,
r.RelationType, r.Confidence,
r.DetectionSource, r.Reason,
)
}
// Store all relations
_ = s.relationStore.StoreRelations(ctx, relations)
}
// ensureSessionExists creates a session if it doesn't exist.
func (s *ObservationStore) ensureSessionExists(ctx context.Context, sdkSessionID, project string) error {
return EnsureSessionExists(ctx, s.store, sdkSessionID, project)
@@ -96,13 +222,7 @@ func (s *ObservationStore) ensureSessionExists(ctx context.Context, sdkSessionID
// GetObservationByID retrieves an observation by ID.
func (s *ObservationStore) GetObservationByID(ctx context.Context, id int64) (*models.Observation, error) {
const query = `
SELECT id, sdk_session_id, project, COALESCE(scope, 'project') as scope, type, title, subtitle, facts, narrative,
concepts, files_read, files_modified, file_mtimes, prompt_number, discovery_tokens,
created_at, created_at_epoch
FROM observations
WHERE id = ?
`
query := `SELECT ` + observationColumns + ` FROM observations WHERE id = ?`
obs, err := scanObservation(s.store.QueryRowContext(ctx, query, id))
if err == sql.ErrNoRows {
@@ -112,6 +232,7 @@ func (s *ObservationStore) GetObservationByID(ctx context.Context, id int64) (*m
}
// GetObservationsByIDs retrieves observations by a list of IDs.
// Results are ordered by importance_score DESC by default, with created_at_epoch as secondary sort.
func (s *ObservationStore) GetObservationsByIDs(ctx context.Context, ids []int64, orderBy string, limit int) ([]*models.Observation, error) {
if len(ids) == 0 {
return nil, nil
@@ -119,18 +240,22 @@ func (s *ObservationStore) GetObservationsByIDs(ctx context.Context, ids []int64
// Build query with placeholders
// #nosec G202 -- query uses parameterized placeholders, not user input
query := `
SELECT id, sdk_session_id, project, COALESCE(scope, 'project') as scope, type, title, subtitle, facts, narrative,
concepts, files_read, files_modified, file_mtimes, prompt_number, discovery_tokens,
created_at, created_at_epoch
query := `SELECT ` + observationColumns + `
FROM observations
WHERE id IN (?` + repeatPlaceholders(len(ids)-1) + `)
ORDER BY created_at_epoch `
ORDER BY `
if orderBy == "date_asc" {
query += "ASC"
} else {
query += "DESC"
// Default to importance-based ordering
switch orderBy {
case "date_asc":
query += "created_at_epoch ASC"
case "date_desc":
query += "created_at_epoch DESC"
case "importance":
query += "importance_score DESC, created_at_epoch DESC"
default:
// Default: importance first, then recency
query += "COALESCE(importance_score, 1.0) DESC, created_at_epoch DESC"
}
if limit > 0 {
@@ -154,14 +279,56 @@ func (s *ObservationStore) GetObservationsByIDs(ctx context.Context, ids []int64
// GetRecentObservations retrieves recent observations for a project.
// This includes project-scoped observations for the specified project AND global observations.
// Results are ordered by importance_score DESC, then created_at_epoch DESC.
func (s *ObservationStore) GetRecentObservations(ctx context.Context, project string, limit int) ([]*models.Observation, error) {
const query = `
SELECT id, sdk_session_id, project, COALESCE(scope, 'project') as scope, type, title, subtitle, facts, narrative,
concepts, files_read, files_modified, file_mtimes, prompt_number, discovery_tokens,
created_at, created_at_epoch
query := `SELECT ` + observationColumns + `
FROM observations
WHERE (project = ? AND (scope IS NULL OR scope = 'project'))
OR scope = 'global'
ORDER BY COALESCE(importance_score, 1.0) DESC, created_at_epoch DESC
LIMIT ?
`
rows, err := s.store.QueryContext(ctx, query, project, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanObservationRows(rows)
}
// GetActiveObservations retrieves recent non-superseded observations for a project.
// This excludes observations that have been marked as superseded by newer ones.
// Use this for context injection where you want to avoid outdated/contradicted advice.
// Results are ordered by importance_score DESC, then created_at_epoch DESC.
func (s *ObservationStore) GetActiveObservations(ctx context.Context, project string, limit int) ([]*models.Observation, error) {
query := `SELECT ` + observationColumns + `
FROM observations
WHERE ((project = ? AND (scope IS NULL OR scope = 'project'))
OR scope = 'global')
AND COALESCE(is_superseded, 0) = 0
ORDER BY COALESCE(importance_score, 1.0) DESC, created_at_epoch DESC
LIMIT ?
`
rows, err := s.store.QueryContext(ctx, query, project, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanObservationRows(rows)
}
// GetSupersededObservations retrieves observations that have been superseded by newer ones.
// Use this for verification/debugging to see which observations were marked as outdated.
// Results are ordered by created_at_epoch DESC.
func (s *ObservationStore) GetSupersededObservations(ctx context.Context, project string, limit int) ([]*models.Observation, error) {
query := `SELECT ` + observationColumns + `
FROM observations
WHERE project = ?
AND COALESCE(is_superseded, 0) = 1
ORDER BY created_at_epoch DESC
LIMIT ?
`
@@ -178,14 +345,12 @@ func (s *ObservationStore) GetRecentObservations(ctx context.Context, project st
// GetObservationsByProjectStrict retrieves observations strictly for a specific project.
// Unlike GetRecentObservations, this does NOT include global observations from other projects.
// Use this for dashboard filtering where the user expects to see only that project's data.
// Results are ordered by importance_score DESC, then created_at_epoch DESC.
func (s *ObservationStore) GetObservationsByProjectStrict(ctx context.Context, project string, limit int) ([]*models.Observation, error) {
const query = `
SELECT id, sdk_session_id, project, COALESCE(scope, 'project') as scope, type, title, subtitle, facts, narrative,
concepts, files_read, files_modified, file_mtimes, prompt_number, discovery_tokens,
created_at, created_at_epoch
query := `SELECT ` + observationColumns + `
FROM observations
WHERE project = ?
ORDER BY created_at_epoch DESC
ORDER BY COALESCE(importance_score, 1.0) DESC, created_at_epoch DESC
LIMIT ?
`
@@ -210,13 +375,11 @@ func (s *ObservationStore) GetObservationCount(ctx context.Context, project stri
}
// GetAllRecentObservations retrieves recent observations across all projects.
// Results are ordered by importance_score DESC, then created_at_epoch DESC.
func (s *ObservationStore) GetAllRecentObservations(ctx context.Context, limit int) ([]*models.Observation, error) {
const query = `
SELECT id, sdk_session_id, project, COALESCE(scope, 'project') as scope, type, title, subtitle, facts, narrative,
concepts, files_read, files_modified, file_mtimes, prompt_number, discovery_tokens,
created_at, created_at_epoch
query := `SELECT ` + observationColumns + `
FROM observations
ORDER BY created_at_epoch DESC
ORDER BY COALESCE(importance_score, 1.0) DESC, created_at_epoch DESC
LIMIT ?
`
@@ -229,7 +392,24 @@ func (s *ObservationStore) GetAllRecentObservations(ctx context.Context, limit i
return scanObservationRows(rows)
}
// GetAllObservations retrieves all observations (for vector rebuild).
func (s *ObservationStore) GetAllObservations(ctx context.Context) ([]*models.Observation, error) {
query := `SELECT ` + observationColumns + `
FROM observations
ORDER BY id
`
rows, err := s.store.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
return scanObservationRows(rows)
}
// SearchObservationsFTS performs full-text search on observations.
// Results are ordered by FTS rank (relevance), then by importance_score.
func (s *ObservationStore) SearchObservationsFTS(ctx context.Context, query, project string, limit int) ([]*models.Observation, error) {
if limit <= 0 {
limit = 10
@@ -245,15 +425,21 @@ func (s *ObservationStore) SearchObservationsFTS(ctx context.Context, query, pro
ftsTerms := strings.Join(keywords, " OR ")
// Use FTS5 to search title, subtitle, and narrative
const ftsQuery = `
// Include importance scoring columns and order by rank then importance
ftsQuery := `
SELECT o.id, o.sdk_session_id, o.project, COALESCE(o.scope, 'project') as scope, o.type,
o.title, o.subtitle, o.facts, o.narrative, o.concepts, o.files_read, o.files_modified,
o.file_mtimes, o.prompt_number, o.discovery_tokens, o.created_at, o.created_at_epoch
o.file_mtimes, o.prompt_number, o.discovery_tokens, o.created_at, o.created_at_epoch,
COALESCE(o.importance_score, 1.0) as importance_score,
COALESCE(o.user_feedback, 0) as user_feedback,
COALESCE(o.retrieval_count, 0) as retrieval_count,
o.last_retrieved_at_epoch, o.score_updated_at_epoch,
COALESCE(o.is_superseded, 0) as is_superseded
FROM observations o
JOIN observations_fts fts ON o.id = fts.rowid
WHERE observations_fts MATCH ?
AND (o.project = ? OR o.scope = 'global')
ORDER BY rank
ORDER BY rank, COALESCE(o.importance_score, 1.0) DESC
LIMIT ?
`
@@ -278,6 +464,7 @@ func (s *ObservationStore) SearchObservationsFTS(ctx context.Context, query, pro
}
// searchObservationsLike performs fallback LIKE search on observations.
// Results are ordered by importance_score DESC, then created_at_epoch DESC.
func (s *ObservationStore) searchObservationsLike(ctx context.Context, keywords []string, project string, limit int) ([]*models.Observation, error) {
if len(keywords) == 0 {
return nil, nil
@@ -294,14 +481,11 @@ func (s *ObservationStore) searchObservationsLike(ctx context.Context, keywords
}
// #nosec G202 -- query uses parameterized placeholders, not user input
query := `
SELECT id, sdk_session_id, project, COALESCE(scope, 'project') as scope, type,
title, subtitle, facts, narrative, concepts, files_read, files_modified,
file_mtimes, prompt_number, discovery_tokens, created_at, created_at_epoch
query := `SELECT ` + observationColumns + `
FROM observations
WHERE (` + strings.Join(conditions, " OR ") + `)
AND (project = ? OR scope = 'global')
ORDER BY created_at_epoch DESC
ORDER BY COALESCE(importance_score, 1.0) DESC, created_at_epoch DESC
LIMIT ?
`
args = append(args, project, limit)
@@ -445,6 +629,11 @@ func scanObservation(scanner interface{ Scan(...interface{}) error }) (*models.O
&obs.Concepts, &obs.FilesRead, &obs.FilesModified, &obs.FileMtimes,
&obs.PromptNumber, &obs.DiscoveryTokens,
&obs.CreatedAt, &obs.CreatedAtEpoch,
// Importance scoring fields
&obs.ImportanceScore, &obs.UserFeedback, &obs.RetrievalCount,
&obs.LastRetrievedAt, &obs.ScoreUpdatedAt,
// Conflict detection fields
&obs.IsSuperseded,
); err != nil {
return nil, err
}
+370
View File
@@ -0,0 +1,370 @@
// Package sqlite provides SQLite database operations for claude-mnemonic.
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
)
// patternColumns is the standard list of columns to select for patterns.
const patternColumns = `id, name, type, description, signature, recommendation,
frequency, projects, observation_ids, status, merged_into_id, confidence,
last_seen_at, last_seen_at_epoch, created_at, created_at_epoch`
// PatternCleanupFunc is a callback for when patterns are deleted.
type PatternCleanupFunc func(ctx context.Context, deletedIDs []int64)
// PatternStore provides pattern-related database operations.
type PatternStore struct {
store *Store
cleanupFunc PatternCleanupFunc
}
// NewPatternStore creates a new pattern store.
func NewPatternStore(store *Store) *PatternStore {
return &PatternStore{store: store}
}
// SetCleanupFunc sets the callback for when patterns are deleted.
func (s *PatternStore) SetCleanupFunc(fn PatternCleanupFunc) {
s.cleanupFunc = fn
}
// StorePattern stores a new pattern.
func (s *PatternStore) StorePattern(ctx context.Context, pattern *models.Pattern) (int64, error) {
signatureJSON, _ := json.Marshal(pattern.Signature)
projectsJSON, _ := json.Marshal(pattern.Projects)
obsIDsJSON, _ := json.Marshal(pattern.ObservationIDs)
const query = `
INSERT INTO patterns
(name, type, description, signature, recommendation, frequency, projects,
observation_ids, status, merged_into_id, confidence,
last_seen_at, last_seen_at_epoch, created_at, created_at_epoch)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
result, err := s.store.ExecContext(ctx, query,
pattern.Name, string(pattern.Type),
nullString(pattern.Description.String), string(signatureJSON),
nullString(pattern.Recommendation.String),
pattern.Frequency, string(projectsJSON), string(obsIDsJSON),
string(pattern.Status), nullInt64(pattern.MergedIntoID),
pattern.Confidence, pattern.LastSeenAt, pattern.LastSeenEpoch,
pattern.CreatedAt, pattern.CreatedAtEpoch,
)
if err != nil {
return 0, err
}
return result.LastInsertId()
}
// UpdatePattern updates an existing pattern.
func (s *PatternStore) UpdatePattern(ctx context.Context, pattern *models.Pattern) error {
signatureJSON, _ := json.Marshal(pattern.Signature)
projectsJSON, _ := json.Marshal(pattern.Projects)
obsIDsJSON, _ := json.Marshal(pattern.ObservationIDs)
const query = `
UPDATE patterns SET
name = ?, type = ?, description = ?, signature = ?, recommendation = ?,
frequency = ?, projects = ?, observation_ids = ?, status = ?,
merged_into_id = ?, confidence = ?, last_seen_at = ?, last_seen_at_epoch = ?
WHERE id = ?
`
_, err := s.store.ExecContext(ctx, query,
pattern.Name, string(pattern.Type),
nullString(pattern.Description.String), string(signatureJSON),
nullString(pattern.Recommendation.String),
pattern.Frequency, string(projectsJSON), string(obsIDsJSON),
string(pattern.Status), nullInt64(pattern.MergedIntoID),
pattern.Confidence, pattern.LastSeenAt, pattern.LastSeenEpoch,
pattern.ID,
)
return err
}
// GetPatternByID retrieves a pattern by ID.
func (s *PatternStore) GetPatternByID(ctx context.Context, id int64) (*models.Pattern, error) {
query := `SELECT ` + patternColumns + ` FROM patterns WHERE id = ?`
row := s.store.QueryRowContext(ctx, query, id)
return scanPattern(row)
}
// GetPatternByName retrieves a pattern by name.
func (s *PatternStore) GetPatternByName(ctx context.Context, name string) (*models.Pattern, error) {
query := `SELECT ` + patternColumns + ` FROM patterns WHERE name = ? AND status = 'active'`
row := s.store.QueryRowContext(ctx, query, name)
pattern, err := scanPattern(row)
if err == sql.ErrNoRows {
return nil, nil
}
return pattern, err
}
// GetActivePatterns retrieves all active patterns.
func (s *PatternStore) GetActivePatterns(ctx context.Context, limit int) ([]*models.Pattern, error) {
query := `SELECT ` + patternColumns + `
FROM patterns
WHERE status = 'active'
ORDER BY frequency DESC, confidence DESC
LIMIT ?`
rows, err := s.store.QueryContext(ctx, query, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPatternRows(rows)
}
// GetPatternsByType retrieves patterns of a specific type.
func (s *PatternStore) GetPatternsByType(ctx context.Context, patternType models.PatternType, limit int) ([]*models.Pattern, error) {
query := `SELECT ` + patternColumns + `
FROM patterns
WHERE type = ? AND status = 'active'
ORDER BY frequency DESC, confidence DESC
LIMIT ?`
rows, err := s.store.QueryContext(ctx, query, string(patternType), limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPatternRows(rows)
}
// GetPatternsByProject retrieves patterns that have been observed in a specific project.
func (s *PatternStore) GetPatternsByProject(ctx context.Context, project string, limit int) ([]*models.Pattern, error) {
// Use JSON path to search within the projects array
query := `SELECT ` + patternColumns + `
FROM patterns
WHERE status = 'active'
AND EXISTS (
SELECT 1 FROM json_each(projects)
WHERE json_each.value = ?
)
ORDER BY frequency DESC, confidence DESC
LIMIT ?`
rows, err := s.store.QueryContext(ctx, query, project, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPatternRows(rows)
}
// FindMatchingPatterns searches for patterns that match a given signature.
func (s *PatternStore) FindMatchingPatterns(ctx context.Context, signature []string, minScore float64) ([]*models.Pattern, error) {
// Get all active patterns and filter by signature match in Go
// This is simpler than complex SQL for JSON array matching
patterns, err := s.GetActivePatterns(ctx, 100)
if err != nil {
return nil, err
}
var matches []*models.Pattern
for _, pattern := range patterns {
score := models.CalculateMatchScore(signature, pattern.Signature)
if score >= minScore {
matches = append(matches, pattern)
}
}
return matches, nil
}
// MarkPatternDeprecated marks a pattern as deprecated.
func (s *PatternStore) MarkPatternDeprecated(ctx context.Context, id int64) error {
const query = `UPDATE patterns SET status = 'deprecated' WHERE id = ?`
_, err := s.store.ExecContext(ctx, query, id)
return err
}
// MergePatterns merges a source pattern into a target pattern.
func (s *PatternStore) MergePatterns(ctx context.Context, sourceID, targetID int64) error {
// Get both patterns
source, err := s.GetPatternByID(ctx, sourceID)
if err != nil {
return err
}
target, err := s.GetPatternByID(ctx, targetID)
if err != nil {
return err
}
// Merge source into target
target.Frequency += source.Frequency
for _, proj := range source.Projects {
found := false
for _, existing := range target.Projects {
if existing == proj {
found = true
break
}
}
if !found {
target.Projects = append(target.Projects, proj)
}
}
for _, obsID := range source.ObservationIDs {
found := false
for _, existing := range target.ObservationIDs {
if existing == obsID {
found = true
break
}
}
if !found {
target.ObservationIDs = append(target.ObservationIDs, obsID)
}
}
// Update target
if err := s.UpdatePattern(ctx, target); err != nil {
return err
}
// Mark source as merged
source.Status = models.PatternStatusMerged
source.MergedIntoID = sql.NullInt64{Int64: targetID, Valid: true}
return s.UpdatePattern(ctx, source)
}
// DeletePattern deletes a pattern by ID.
func (s *PatternStore) DeletePattern(ctx context.Context, id int64) error {
const query = `DELETE FROM patterns WHERE id = ?`
_, err := s.store.ExecContext(ctx, query, id)
if err == nil && s.cleanupFunc != nil {
s.cleanupFunc(ctx, []int64{id})
}
return err
}
// SearchPatternsFTS performs full-text search on patterns.
func (s *PatternStore) SearchPatternsFTS(ctx context.Context, searchQuery string, limit int) ([]*models.Pattern, error) {
query := `SELECT p.` + patternColumns + `
FROM patterns p
JOIN patterns_fts fts ON p.id = fts.rowid
WHERE patterns_fts MATCH ?
AND p.status = 'active'
ORDER BY rank
LIMIT ?`
rows, err := s.store.QueryContext(ctx, query, searchQuery, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPatternRows(rows)
}
// GetPatternStats returns statistics about patterns.
func (s *PatternStore) GetPatternStats(ctx context.Context) (*PatternStats, error) {
const query = `
SELECT
COUNT(*) as total,
COUNT(CASE WHEN status = 'active' THEN 1 END) as active,
COUNT(CASE WHEN status = 'deprecated' THEN 1 END) as deprecated,
COUNT(CASE WHEN status = 'merged' THEN 1 END) as merged,
COALESCE(SUM(frequency), 0) as total_occurrences,
COALESCE(AVG(confidence), 0) as avg_confidence,
COUNT(CASE WHEN type = 'bug' THEN 1 END) as bugs,
COUNT(CASE WHEN type = 'refactor' THEN 1 END) as refactors,
COUNT(CASE WHEN type = 'architecture' THEN 1 END) as architectures,
COUNT(CASE WHEN type = 'anti-pattern' THEN 1 END) as anti_patterns,
COUNT(CASE WHEN type = 'best-practice' THEN 1 END) as best_practices
FROM patterns
`
var stats PatternStats
err := s.store.QueryRowContext(ctx, query).Scan(
&stats.Total, &stats.Active, &stats.Deprecated, &stats.Merged,
&stats.TotalOccurrences, &stats.AvgConfidence,
&stats.Bugs, &stats.Refactors, &stats.Architectures,
&stats.AntiPatterns, &stats.BestPractices,
)
return &stats, err
}
// PatternStats contains aggregate statistics about patterns.
type PatternStats struct {
Total int `json:"total"`
Active int `json:"active"`
Deprecated int `json:"deprecated"`
Merged int `json:"merged"`
TotalOccurrences int `json:"total_occurrences"`
AvgConfidence float64 `json:"avg_confidence"`
Bugs int `json:"bugs"`
Refactors int `json:"refactors"`
Architectures int `json:"architectures"`
AntiPatterns int `json:"anti_patterns"`
BestPractices int `json:"best_practices"`
}
// scanPattern scans a single pattern from a row scanner.
func scanPattern(scanner interface{ Scan(...interface{}) error }) (*models.Pattern, error) {
var pattern models.Pattern
if err := scanner.Scan(
&pattern.ID, &pattern.Name, &pattern.Type,
&pattern.Description, &pattern.Signature, &pattern.Recommendation,
&pattern.Frequency, &pattern.Projects, &pattern.ObservationIDs,
&pattern.Status, &pattern.MergedIntoID, &pattern.Confidence,
&pattern.LastSeenAt, &pattern.LastSeenEpoch,
&pattern.CreatedAt, &pattern.CreatedAtEpoch,
); err != nil {
return nil, err
}
return &pattern, nil
}
// scanPatternRows scans multiple patterns from rows.
func scanPatternRows(rows *sql.Rows) ([]*models.Pattern, error) {
var patterns []*models.Pattern
for rows.Next() {
pattern, err := scanPattern(rows)
if err != nil {
return nil, err
}
patterns = append(patterns, pattern)
}
return patterns, rows.Err()
}
// nullInt64 converts sql.NullInt64 to the value needed for database insertion.
func nullInt64(n sql.NullInt64) interface{} {
if n.Valid {
return n.Int64
}
return nil
}
// IncrementPatternFrequency atomically increments a pattern's frequency and updates last_seen.
func (s *PatternStore) IncrementPatternFrequency(ctx context.Context, id int64, project string, observationID int64) error {
now := time.Now()
// Get current pattern
pattern, err := s.GetPatternByID(ctx, id)
if err != nil {
return err
}
// Add occurrence
pattern.AddOccurrence(project, observationID)
pattern.LastSeenAt = now.Format(time.RFC3339)
pattern.LastSeenEpoch = now.UnixMilli()
return s.UpdatePattern(ctx, pattern)
}
+507
View File
@@ -0,0 +1,507 @@
package sqlite
import (
"context"
"database/sql"
"testing"
"time"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
)
// setupPatternTestStore creates a test store with patterns table.
func setupPatternTestStore(t *testing.T) *Store {
t.Helper()
db, _, cleanup := testDB(t)
t.Cleanup(cleanup)
createBaseTables(t, db)
return newStoreFromDB(db)
}
func TestPatternStore_StoreAndGet(t *testing.T) {
store := setupPatternTestStore(t)
patternStore := NewPatternStore(store)
ctx := context.Background()
// Create test pattern
pattern := &models.Pattern{
Name: "Test Pattern",
Type: models.PatternTypeBug,
Description: sql.NullString{String: "A test pattern", Valid: true},
Signature: []string{"nil", "error"},
Recommendation: sql.NullString{String: "Always check for nil", Valid: true},
Frequency: 1,
Projects: []string{"project1"},
ObservationIDs: []int64{1, 2},
Status: models.PatternStatusActive,
Confidence: 0.5,
LastSeenAt: time.Now().Format(time.RFC3339),
LastSeenEpoch: time.Now().UnixMilli(),
CreatedAt: time.Now().Format(time.RFC3339),
CreatedAtEpoch: time.Now().UnixMilli(),
}
// Store pattern
id, err := patternStore.StorePattern(ctx, pattern)
if err != nil {
t.Fatalf("StorePattern() error = %v", err)
}
if id <= 0 {
t.Errorf("Expected positive ID, got %d", id)
}
// Get pattern by ID
retrieved, err := patternStore.GetPatternByID(ctx, id)
if err != nil {
t.Fatalf("GetPatternByID() error = %v", err)
}
if retrieved.Name != pattern.Name {
t.Errorf("Expected name %s, got %s", pattern.Name, retrieved.Name)
}
if retrieved.Type != pattern.Type {
t.Errorf("Expected type %s, got %s", pattern.Type, retrieved.Type)
}
if len(retrieved.Signature) != len(pattern.Signature) {
t.Errorf("Expected %d signature elements, got %d",
len(pattern.Signature), len(retrieved.Signature))
}
}
func TestPatternStore_GetByName(t *testing.T) {
store := setupPatternTestStore(t)
patternStore := NewPatternStore(store)
ctx := context.Background()
pattern := createTestPattern("Unique Name Pattern")
_, err := patternStore.StorePattern(ctx, pattern)
if err != nil {
t.Fatalf("StorePattern() error = %v", err)
}
// Get by name
retrieved, err := patternStore.GetPatternByName(ctx, "Unique Name Pattern")
if err != nil {
t.Fatalf("GetPatternByName() error = %v", err)
}
if retrieved == nil {
t.Fatal("Expected pattern, got nil")
}
if retrieved.Name != "Unique Name Pattern" {
t.Errorf("Expected name 'Unique Name Pattern', got '%s'", retrieved.Name)
}
// Get non-existent pattern
nonExistent, err := patternStore.GetPatternByName(ctx, "Non Existent")
if err != nil {
t.Fatalf("GetPatternByName() error = %v", err)
}
if nonExistent != nil {
t.Errorf("Expected nil for non-existent pattern, got %v", nonExistent)
}
}
func TestPatternStore_GetActivePatterns(t *testing.T) {
store := setupPatternTestStore(t)
patternStore := NewPatternStore(store)
ctx := context.Background()
// Create multiple patterns with different statuses
active1 := createTestPattern("Active 1")
active1.Frequency = 5
active2 := createTestPattern("Active 2")
active2.Frequency = 3
deprecated := createTestPattern("Deprecated")
deprecated.Status = models.PatternStatusDeprecated
patternStore.StorePattern(ctx, active1)
patternStore.StorePattern(ctx, active2)
patternStore.StorePattern(ctx, deprecated)
// Get active patterns
patterns, err := patternStore.GetActivePatterns(ctx, 10)
if err != nil {
t.Fatalf("GetActivePatterns() error = %v", err)
}
if len(patterns) != 2 {
t.Errorf("Expected 2 active patterns, got %d", len(patterns))
}
// Check order (should be by frequency descending)
if len(patterns) >= 2 {
if patterns[0].Frequency < patterns[1].Frequency {
t.Errorf("Patterns not ordered by frequency descending")
}
}
}
func TestPatternStore_GetPatternsByType(t *testing.T) {
store := setupPatternTestStore(t)
patternStore := NewPatternStore(store)
ctx := context.Background()
// Create patterns of different types
bugPattern := createTestPattern("Bug Pattern")
bugPattern.Type = models.PatternTypeBug
refactorPattern := createTestPattern("Refactor Pattern")
refactorPattern.Type = models.PatternTypeRefactor
patternStore.StorePattern(ctx, bugPattern)
patternStore.StorePattern(ctx, refactorPattern)
// Get by type
bugs, err := patternStore.GetPatternsByType(ctx, models.PatternTypeBug, 10)
if err != nil {
t.Fatalf("GetPatternsByType() error = %v", err)
}
if len(bugs) != 1 {
t.Errorf("Expected 1 bug pattern, got %d", len(bugs))
}
refactors, err := patternStore.GetPatternsByType(ctx, models.PatternTypeRefactor, 10)
if err != nil {
t.Fatalf("GetPatternsByType() error = %v", err)
}
if len(refactors) != 1 {
t.Errorf("Expected 1 refactor pattern, got %d", len(refactors))
}
}
func TestPatternStore_GetPatternsByProject(t *testing.T) {
store := setupPatternTestStore(t)
patternStore := NewPatternStore(store)
ctx := context.Background()
// Create patterns with different projects
pattern1 := createTestPattern("Pattern 1")
pattern1.Projects = []string{"project-a", "project-b"}
pattern2 := createTestPattern("Pattern 2")
pattern2.Projects = []string{"project-b", "project-c"}
patternStore.StorePattern(ctx, pattern1)
patternStore.StorePattern(ctx, pattern2)
// Get by project
projectA, err := patternStore.GetPatternsByProject(ctx, "project-a", 10)
if err != nil {
t.Fatalf("GetPatternsByProject() error = %v", err)
}
if len(projectA) != 1 {
t.Errorf("Expected 1 pattern for project-a, got %d", len(projectA))
}
projectB, err := patternStore.GetPatternsByProject(ctx, "project-b", 10)
if err != nil {
t.Fatalf("GetPatternsByProject() error = %v", err)
}
if len(projectB) != 2 {
t.Errorf("Expected 2 patterns for project-b, got %d", len(projectB))
}
}
func TestPatternStore_UpdatePattern(t *testing.T) {
store := setupPatternTestStore(t)
patternStore := NewPatternStore(store)
ctx := context.Background()
// Create and store pattern
pattern := createTestPattern("Original Name")
id, _ := patternStore.StorePattern(ctx, pattern)
// Update pattern
pattern.ID = id
pattern.Name = "Updated Name"
pattern.Frequency = 10
pattern.Confidence = 0.9
err := patternStore.UpdatePattern(ctx, pattern)
if err != nil {
t.Fatalf("UpdatePattern() error = %v", err)
}
// Verify update
updated, _ := patternStore.GetPatternByID(ctx, id)
if updated.Name != "Updated Name" {
t.Errorf("Expected name 'Updated Name', got '%s'", updated.Name)
}
if updated.Frequency != 10 {
t.Errorf("Expected frequency 10, got %d", updated.Frequency)
}
if updated.Confidence != 0.9 {
t.Errorf("Expected confidence 0.9, got %f", updated.Confidence)
}
}
func TestPatternStore_DeletePattern(t *testing.T) {
store := setupPatternTestStore(t)
patternStore := NewPatternStore(store)
ctx := context.Background()
// Create and store pattern
pattern := createTestPattern("To Delete")
id, _ := patternStore.StorePattern(ctx, pattern)
// Delete pattern
err := patternStore.DeletePattern(ctx, id)
if err != nil {
t.Fatalf("DeletePattern() error = %v", err)
}
// Verify deletion
deleted, err := patternStore.GetPatternByID(ctx, id)
if err != sql.ErrNoRows {
t.Errorf("Expected ErrNoRows, got %v", err)
}
if deleted != nil {
t.Errorf("Expected nil for deleted pattern")
}
}
func TestPatternStore_MarkPatternDeprecated(t *testing.T) {
store := setupPatternTestStore(t)
patternStore := NewPatternStore(store)
ctx := context.Background()
// Create and store pattern
pattern := createTestPattern("To Deprecate")
id, _ := patternStore.StorePattern(ctx, pattern)
// Mark as deprecated
err := patternStore.MarkPatternDeprecated(ctx, id)
if err != nil {
t.Fatalf("MarkPatternDeprecated() error = %v", err)
}
// Verify status
deprecated, _ := patternStore.GetPatternByID(ctx, id)
if deprecated.Status != models.PatternStatusDeprecated {
t.Errorf("Expected status 'deprecated', got '%s'", deprecated.Status)
}
}
func TestPatternStore_MergePatterns(t *testing.T) {
store := setupPatternTestStore(t)
patternStore := NewPatternStore(store)
ctx := context.Background()
// Create source and target patterns
source := createTestPattern("Source Pattern")
source.Frequency = 3
source.Projects = []string{"proj1", "proj2"}
source.ObservationIDs = []int64{1, 2, 3}
target := createTestPattern("Target Pattern")
target.Frequency = 2
target.Projects = []string{"proj2", "proj3"}
target.ObservationIDs = []int64{4, 5}
sourceID, _ := patternStore.StorePattern(ctx, source)
targetID, _ := patternStore.StorePattern(ctx, target)
// Merge
err := patternStore.MergePatterns(ctx, sourceID, targetID)
if err != nil {
t.Fatalf("MergePatterns() error = %v", err)
}
// Verify source is marked as merged
mergedSource, _ := patternStore.GetPatternByID(ctx, sourceID)
if mergedSource.Status != models.PatternStatusMerged {
t.Errorf("Expected source status 'merged', got '%s'", mergedSource.Status)
}
if !mergedSource.MergedIntoID.Valid || mergedSource.MergedIntoID.Int64 != targetID {
t.Errorf("Expected source merged_into_id to be %d", targetID)
}
// Verify target has combined data
mergedTarget, _ := patternStore.GetPatternByID(ctx, targetID)
expectedFrequency := 5 // 3 + 2
if mergedTarget.Frequency != expectedFrequency {
t.Errorf("Expected merged frequency %d, got %d", expectedFrequency, mergedTarget.Frequency)
}
// Should have 3 unique projects: proj1, proj2, proj3
if len(mergedTarget.Projects) != 3 {
t.Errorf("Expected 3 projects after merge, got %d", len(mergedTarget.Projects))
}
// Should have 5 observation IDs
if len(mergedTarget.ObservationIDs) != 5 {
t.Errorf("Expected 5 observation IDs after merge, got %d", len(mergedTarget.ObservationIDs))
}
}
func TestPatternStore_FindMatchingPatterns(t *testing.T) {
store := setupPatternTestStore(t)
patternStore := NewPatternStore(store)
ctx := context.Background()
// Create patterns with known signatures
pattern1 := createTestPattern("Pattern 1")
pattern1.Signature = []string{"nil", "error", "handling"}
pattern2 := createTestPattern("Pattern 2")
pattern2.Signature = []string{"nil", "pointer", "check"}
pattern3 := createTestPattern("Pattern 3")
pattern3.Signature = []string{"refactor", "extract", "method"}
patternStore.StorePattern(ctx, pattern1)
patternStore.StorePattern(ctx, pattern2)
patternStore.StorePattern(ctx, pattern3)
// Find patterns matching "nil" related signature
matches, err := patternStore.FindMatchingPatterns(ctx, []string{"nil", "error"}, 0.3)
if err != nil {
t.Fatalf("FindMatchingPatterns() error = %v", err)
}
if len(matches) < 1 {
t.Errorf("Expected at least 1 match, got %d", len(matches))
}
// Verify no match for unrelated signature
noMatches, err := patternStore.FindMatchingPatterns(ctx, []string{"completely", "different"}, 0.5)
if err != nil {
t.Fatalf("FindMatchingPatterns() error = %v", err)
}
if len(noMatches) != 0 {
t.Errorf("Expected 0 matches for unrelated signature, got %d", len(noMatches))
}
}
func TestPatternStore_IncrementPatternFrequency(t *testing.T) {
store := setupPatternTestStore(t)
patternStore := NewPatternStore(store)
ctx := context.Background()
// Create pattern
pattern := createTestPattern("Frequency Test")
pattern.Frequency = 1
pattern.Projects = []string{"proj1"}
pattern.ObservationIDs = []int64{1}
id, _ := patternStore.StorePattern(ctx, pattern)
// Increment frequency
err := patternStore.IncrementPatternFrequency(ctx, id, "proj2", 2)
if err != nil {
t.Fatalf("IncrementPatternFrequency() error = %v", err)
}
// Verify
updated, _ := patternStore.GetPatternByID(ctx, id)
if updated.Frequency != 2 {
t.Errorf("Expected frequency 2, got %d", updated.Frequency)
}
if len(updated.Projects) != 2 {
t.Errorf("Expected 2 projects, got %d", len(updated.Projects))
}
if len(updated.ObservationIDs) != 2 {
t.Errorf("Expected 2 observation IDs, got %d", len(updated.ObservationIDs))
}
}
func TestPatternStore_GetPatternStats(t *testing.T) {
store := setupPatternTestStore(t)
patternStore := NewPatternStore(store)
ctx := context.Background()
// Create patterns with different types and statuses
bug := createTestPattern("Bug")
bug.Type = models.PatternTypeBug
bug.Frequency = 5
refactor := createTestPattern("Refactor")
refactor.Type = models.PatternTypeRefactor
refactor.Frequency = 3
deprecated := createTestPattern("Deprecated")
deprecated.Type = models.PatternTypeArchitecture
deprecated.Status = models.PatternStatusDeprecated
patternStore.StorePattern(ctx, bug)
patternStore.StorePattern(ctx, refactor)
patternStore.StorePattern(ctx, deprecated)
// Get stats
stats, err := patternStore.GetPatternStats(ctx)
if err != nil {
t.Fatalf("GetPatternStats() error = %v", err)
}
if stats.Total != 3 {
t.Errorf("Expected total 3, got %d", stats.Total)
}
if stats.Active != 2 {
t.Errorf("Expected 2 active, got %d", stats.Active)
}
if stats.Deprecated != 1 {
t.Errorf("Expected 1 deprecated, got %d", stats.Deprecated)
}
if stats.Bugs != 1 {
t.Errorf("Expected 1 bug, got %d", stats.Bugs)
}
if stats.Refactors != 1 {
t.Errorf("Expected 1 refactor, got %d", stats.Refactors)
}
if stats.TotalOccurrences != 9 { // 5 + 3 + 1
t.Errorf("Expected 9 total occurrences, got %d", stats.TotalOccurrences)
}
}
func TestPatternStore_CleanupCallback(t *testing.T) {
store := setupPatternTestStore(t)
patternStore := NewPatternStore(store)
ctx := context.Background()
var deletedIDs []int64
patternStore.SetCleanupFunc(func(ctx context.Context, ids []int64) {
deletedIDs = ids
})
// Create and delete pattern
pattern := createTestPattern("Cleanup Test")
id, _ := patternStore.StorePattern(ctx, pattern)
patternStore.DeletePattern(ctx, id)
if len(deletedIDs) != 1 || deletedIDs[0] != id {
t.Errorf("Expected cleanup callback with ID %d, got %v", id, deletedIDs)
}
}
// Helper function to create a test pattern
func createTestPattern(name string) *models.Pattern {
now := time.Now()
return &models.Pattern{
Name: name,
Type: models.PatternTypeBug,
Description: sql.NullString{String: "Test description", Valid: true},
Signature: []string{"test", "pattern"},
Recommendation: sql.NullString{String: "Test recommendation", Valid: true},
Frequency: 1,
Projects: []string{"test-project"},
ObservationIDs: []int64{1},
Status: models.PatternStatusActive,
Confidence: 0.5,
LastSeenAt: now.Format(time.RFC3339),
LastSeenEpoch: now.UnixMilli(),
CreatedAt: now.Format(time.RFC3339),
CreatedAtEpoch: now.UnixMilli(),
}
}
+22
View File
@@ -199,6 +199,28 @@ func (s *PromptStore) GetAllRecentUserPrompts(ctx context.Context, limit int) ([
return scanPromptWithSessionRows(rows)
}
// GetAllPrompts retrieves all user prompts (for vector rebuild).
func (s *PromptStore) GetAllPrompts(ctx context.Context) ([]*models.UserPromptWithSession, error) {
const query = `
SELECT up.id, up.claude_session_id, up.prompt_number, up.prompt_text,
COALESCE(up.matched_observations, 0) as matched_observations,
up.created_at, up.created_at_epoch,
COALESCE(s.project, '') as project,
COALESCE(s.sdk_session_id, '') as sdk_session_id
FROM user_prompts up
LEFT JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id
ORDER BY up.id
`
rows, err := s.store.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPromptWithSessionRows(rows)
}
// FindRecentPromptByText finds a prompt with the same text for a session within the last few seconds.
// This is used to detect duplicate hook invocations.
// Returns (promptID, promptNumber, found).
+377
View File
@@ -0,0 +1,377 @@
// Package sqlite provides SQLite database operations for claude-mnemonic.
package sqlite
import (
"context"
"database/sql"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
)
// RelationStore provides relation-related database operations.
type RelationStore struct {
store *Store
}
// NewRelationStore creates a new relation store.
func NewRelationStore(store *Store) *RelationStore {
return &RelationStore{store: store}
}
// StoreRelation stores a new observation relation.
// Uses INSERT OR IGNORE to handle duplicate (source_id, target_id, relation_type) combinations.
func (s *RelationStore) StoreRelation(ctx context.Context, relation *models.ObservationRelation) (int64, error) {
const query = `
INSERT OR IGNORE INTO observation_relations
(source_id, target_id, relation_type, confidence, detection_source, reason, created_at, created_at_epoch)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`
result, err := s.store.ExecContext(ctx, query,
relation.SourceID, relation.TargetID,
string(relation.RelationType), relation.Confidence,
string(relation.DetectionSource), relation.Reason,
relation.CreatedAt, relation.CreatedAtEpoch,
)
if err != nil {
return 0, err
}
return result.LastInsertId()
}
// StoreRelations stores multiple relations in a single transaction.
func (s *RelationStore) StoreRelations(ctx context.Context, relations []*models.ObservationRelation) error {
if len(relations) == 0 {
return nil
}
tx, err := s.store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
const query = `
INSERT OR IGNORE INTO observation_relations
(source_id, target_id, relation_type, confidence, detection_source, reason, created_at, created_at_epoch)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`
stmt, err := tx.PrepareContext(ctx, query)
if err != nil {
return err
}
defer stmt.Close()
for _, rel := range relations {
_, err = stmt.ExecContext(ctx,
rel.SourceID, rel.TargetID,
string(rel.RelationType), rel.Confidence,
string(rel.DetectionSource), rel.Reason,
rel.CreatedAt, rel.CreatedAtEpoch,
)
if err != nil {
return err
}
}
return tx.Commit()
}
// GetRelationsByObservationID retrieves all relations involving an observation (as source or target).
func (s *RelationStore) GetRelationsByObservationID(ctx context.Context, obsID int64) ([]*models.ObservationRelation, error) {
const query = `
SELECT id, source_id, target_id, relation_type, confidence, detection_source, reason,
created_at, created_at_epoch
FROM observation_relations
WHERE source_id = ? OR target_id = ?
ORDER BY confidence DESC, created_at_epoch DESC
`
rows, err := s.store.QueryContext(ctx, query, obsID, obsID)
if err != nil {
return nil, err
}
defer rows.Close()
return s.scanRelationRows(rows)
}
// GetOutgoingRelations retrieves relations where the observation is the source.
func (s *RelationStore) GetOutgoingRelations(ctx context.Context, obsID int64) ([]*models.ObservationRelation, error) {
const query = `
SELECT id, source_id, target_id, relation_type, confidence, detection_source, reason,
created_at, created_at_epoch
FROM observation_relations
WHERE source_id = ?
ORDER BY confidence DESC, created_at_epoch DESC
`
rows, err := s.store.QueryContext(ctx, query, obsID)
if err != nil {
return nil, err
}
defer rows.Close()
return s.scanRelationRows(rows)
}
// GetIncomingRelations retrieves relations where the observation is the target.
func (s *RelationStore) GetIncomingRelations(ctx context.Context, obsID int64) ([]*models.ObservationRelation, error) {
const query = `
SELECT id, source_id, target_id, relation_type, confidence, detection_source, reason,
created_at, created_at_epoch
FROM observation_relations
WHERE target_id = ?
ORDER BY confidence DESC, created_at_epoch DESC
`
rows, err := s.store.QueryContext(ctx, query, obsID)
if err != nil {
return nil, err
}
defer rows.Close()
return s.scanRelationRows(rows)
}
// GetRelationsByType retrieves all relations of a specific type.
func (s *RelationStore) GetRelationsByType(ctx context.Context, relationType models.RelationType, limit int) ([]*models.ObservationRelation, error) {
const query = `
SELECT id, source_id, target_id, relation_type, confidence, detection_source, reason,
created_at, created_at_epoch
FROM observation_relations
WHERE relation_type = ?
ORDER BY confidence DESC, created_at_epoch DESC
LIMIT ?
`
rows, err := s.store.QueryContext(ctx, query, string(relationType), limit)
if err != nil {
return nil, err
}
defer rows.Close()
return s.scanRelationRows(rows)
}
// GetRelationsWithDetails retrieves relations with observation titles for display.
func (s *RelationStore) GetRelationsWithDetails(ctx context.Context, obsID int64) ([]*models.RelationWithDetails, error) {
const query = `
SELECT r.id, r.source_id, r.target_id, r.relation_type, r.confidence, r.detection_source, r.reason,
r.created_at, r.created_at_epoch,
COALESCE(src.title, '') as source_title,
COALESCE(tgt.title, '') as target_title,
src.type as source_type,
tgt.type as target_type
FROM observation_relations r
JOIN observations src ON src.id = r.source_id
JOIN observations tgt ON tgt.id = r.target_id
WHERE r.source_id = ? OR r.target_id = ?
ORDER BY r.confidence DESC, r.created_at_epoch DESC
`
rows, err := s.store.QueryContext(ctx, query, obsID, obsID)
if err != nil {
return nil, err
}
defer rows.Close()
var results []*models.RelationWithDetails
for rows.Next() {
var r models.ObservationRelation
var rwd models.RelationWithDetails
var reason sql.NullString
if err := rows.Scan(
&r.ID, &r.SourceID, &r.TargetID,
&r.RelationType, &r.Confidence, &r.DetectionSource, &reason,
&r.CreatedAt, &r.CreatedAtEpoch,
&rwd.SourceTitle, &rwd.TargetTitle,
&rwd.SourceType, &rwd.TargetType,
); err != nil {
return nil, err
}
if reason.Valid {
r.Reason = reason.String
}
rwd.Relation = &r
results = append(results, &rwd)
}
return results, rows.Err()
}
// GetRelationGraph retrieves a relation graph centered on an observation.
// This returns all observations within N hops from the center.
func (s *RelationStore) GetRelationGraph(ctx context.Context, centerID int64, maxDepth int) (*models.RelationGraph, error) {
// Get all relations involving the center observation
relations, err := s.GetRelationsWithDetails(ctx, centerID)
if err != nil {
return nil, err
}
graph := &models.RelationGraph{
CenterID: centerID,
Relations: relations,
}
// If depth > 1, recursively get relations for connected observations
if maxDepth > 1 {
visited := map[int64]bool{centerID: true}
toVisit := make([]int64, 0)
// Collect IDs of directly connected observations
for _, r := range relations {
if !visited[r.Relation.SourceID] {
toVisit = append(toVisit, r.Relation.SourceID)
visited[r.Relation.SourceID] = true
}
if !visited[r.Relation.TargetID] {
toVisit = append(toVisit, r.Relation.TargetID)
visited[r.Relation.TargetID] = true
}
}
// Get relations for connected observations (depth - 1)
for depth := 1; depth < maxDepth && len(toVisit) > 0; depth++ {
nextLevel := make([]int64, 0)
for _, obsID := range toVisit {
moreRelations, err := s.GetRelationsWithDetails(ctx, obsID)
if err != nil {
continue
}
for _, r := range moreRelations {
// Avoid duplicates
exists := false
for _, existing := range graph.Relations {
if existing.Relation.ID == r.Relation.ID {
exists = true
break
}
}
if !exists {
graph.Relations = append(graph.Relations, r)
}
// Queue next level
if !visited[r.Relation.SourceID] {
nextLevel = append(nextLevel, r.Relation.SourceID)
visited[r.Relation.SourceID] = true
}
if !visited[r.Relation.TargetID] {
nextLevel = append(nextLevel, r.Relation.TargetID)
visited[r.Relation.TargetID] = true
}
}
}
toVisit = nextLevel
}
}
return graph, nil
}
// DeleteRelationsByObservationID deletes all relations involving an observation.
// Called when an observation is deleted.
func (s *RelationStore) DeleteRelationsByObservationID(ctx context.Context, obsID int64) error {
const query = `DELETE FROM observation_relations WHERE source_id = ? OR target_id = ?`
_, err := s.store.ExecContext(ctx, query, obsID, obsID)
return err
}
// GetRelationCount returns the count of relations for an observation.
func (s *RelationStore) GetRelationCount(ctx context.Context, obsID int64) (int, error) {
const query = `
SELECT COUNT(*) FROM observation_relations
WHERE source_id = ? OR target_id = ?
`
var count int
err := s.store.QueryRowContext(ctx, query, obsID, obsID).Scan(&count)
return count, err
}
// GetTotalRelationCount returns the total count of all relations.
func (s *RelationStore) GetTotalRelationCount(ctx context.Context) (int, error) {
const query = `SELECT COUNT(*) FROM observation_relations`
var count int
err := s.store.QueryRowContext(ctx, query).Scan(&count)
return count, err
}
// GetHighConfidenceRelations retrieves relations with confidence above threshold.
func (s *RelationStore) GetHighConfidenceRelations(ctx context.Context, minConfidence float64, limit int) ([]*models.ObservationRelation, error) {
const query = `
SELECT id, source_id, target_id, relation_type, confidence, detection_source, reason,
created_at, created_at_epoch
FROM observation_relations
WHERE confidence >= ?
ORDER BY confidence DESC, created_at_epoch DESC
LIMIT ?
`
rows, err := s.store.QueryContext(ctx, query, minConfidence, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return s.scanRelationRows(rows)
}
// UpdateRelationConfidence updates the confidence of a relation.
func (s *RelationStore) UpdateRelationConfidence(ctx context.Context, relationID int64, newConfidence float64) error {
const query = `UPDATE observation_relations SET confidence = ? WHERE id = ?`
_, err := s.store.ExecContext(ctx, query, newConfidence, relationID)
return err
}
// GetRelatedObservationIDs returns IDs of observations related to the given one.
// This is useful for expanding search results.
func (s *RelationStore) GetRelatedObservationIDs(ctx context.Context, obsID int64, minConfidence float64) ([]int64, error) {
const query = `
SELECT DISTINCT CASE WHEN source_id = ? THEN target_id ELSE source_id END as related_id
FROM observation_relations
WHERE (source_id = ? OR target_id = ?) AND confidence >= ?
`
rows, err := s.store.QueryContext(ctx, query, obsID, obsID, obsID, minConfidence)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// scanRelationRows scans multiple relations from rows.
func (s *RelationStore) scanRelationRows(rows *sql.Rows) ([]*models.ObservationRelation, error) {
var relations []*models.ObservationRelation
for rows.Next() {
var r models.ObservationRelation
var reason sql.NullString
if err := rows.Scan(
&r.ID, &r.SourceID, &r.TargetID,
&r.RelationType, &r.Confidence, &r.DetectionSource, &reason,
&r.CreatedAt, &r.CreatedAtEpoch,
); err != nil {
return nil, err
}
if reason.Valid {
r.Reason = reason.String
}
relations = append(relations, &r)
}
return relations, rows.Err()
}
+324
View File
@@ -0,0 +1,324 @@
// Package sqlite provides SQLite database operations for claude-mnemonic.
package sqlite
import (
"context"
"database/sql"
"time"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
)
// UpdateObservationFeedback updates the user feedback for an observation.
// Feedback values: -1 (thumbs down), 0 (neutral), 1 (thumbs up).
func (s *ObservationStore) UpdateObservationFeedback(ctx context.Context, id int64, feedback int) error {
const query = `
UPDATE observations
SET user_feedback = ?, score_updated_at_epoch = ?
WHERE id = ?
`
_, err := s.store.ExecContext(ctx, query, feedback, time.Now().UnixMilli(), id)
return err
}
// IncrementRetrievalCount increments the retrieval counter for the given observation IDs.
// This is called when observations are returned in search results.
func (s *ObservationStore) IncrementRetrievalCount(ctx context.Context, ids []int64) error {
if len(ids) == 0 {
return nil
}
now := time.Now().UnixMilli()
// Build query with placeholders
// #nosec G202 -- query uses parameterized placeholders, not user input
query := `
UPDATE observations
SET retrieval_count = COALESCE(retrieval_count, 0) + 1,
last_retrieved_at_epoch = ?
WHERE id IN (?` + repeatPlaceholders(len(ids)-1) + `)
`
args := make([]interface{}, 0, len(ids)+1)
args = append(args, now)
for _, id := range ids {
args = append(args, id)
}
_, err := s.store.db.ExecContext(ctx, query, args...)
return err
}
// UpdateImportanceScore updates the importance score for a single observation.
func (s *ObservationStore) UpdateImportanceScore(ctx context.Context, id int64, score float64) error {
const query = `
UPDATE observations
SET importance_score = ?, score_updated_at_epoch = ?
WHERE id = ?
`
_, err := s.store.ExecContext(ctx, query, score, time.Now().UnixMilli(), id)
return err
}
// UpdateImportanceScores bulk updates importance scores for multiple observations.
// This is more efficient than individual updates for batch recalculation.
func (s *ObservationStore) UpdateImportanceScores(ctx context.Context, scores map[int64]float64) error {
if len(scores) == 0 {
return nil
}
tx, err := s.store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := time.Now().UnixMilli()
stmt, err := tx.PrepareContext(ctx, `
UPDATE observations
SET importance_score = ?, score_updated_at_epoch = ?
WHERE id = ?
`)
if err != nil {
return err
}
defer stmt.Close()
for id, score := range scores {
if _, err := stmt.ExecContext(ctx, score, now, id); err != nil {
return err
}
}
return tx.Commit()
}
// GetObservationsNeedingScoreUpdate returns observations that need their importance score recalculated.
// Returns observations where score_updated_at_epoch is NULL or older than the threshold.
func (s *ObservationStore) GetObservationsNeedingScoreUpdate(ctx context.Context, threshold time.Duration, limit int) ([]*models.Observation, error) {
cutoff := time.Now().Add(-threshold).UnixMilli()
query := `SELECT ` + observationColumns + `
FROM observations
WHERE score_updated_at_epoch IS NULL OR score_updated_at_epoch < ?
ORDER BY created_at_epoch DESC
LIMIT ?
`
rows, err := s.store.QueryContext(ctx, query, cutoff, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanObservationRows(rows)
}
// GetConceptWeights returns all concept weights from the database.
func (s *ObservationStore) GetConceptWeights(ctx context.Context) (map[string]float64, error) {
const query = `SELECT concept, weight FROM concept_weights`
rows, err := s.store.QueryContext(ctx, query)
if err != nil {
// Table might not exist in older databases
if err == sql.ErrNoRows {
return models.DefaultConceptWeights, nil
}
return nil, err
}
defer rows.Close()
weights := make(map[string]float64)
for rows.Next() {
var concept string
var weight float64
if err := rows.Scan(&concept, &weight); err != nil {
return nil, err
}
weights[concept] = weight
}
if err := rows.Err(); err != nil {
return nil, err
}
// If no weights found, use defaults
if len(weights) == 0 {
return models.DefaultConceptWeights, nil
}
return weights, nil
}
// UpdateConceptWeight updates a single concept weight.
func (s *ObservationStore) UpdateConceptWeight(ctx context.Context, concept string, weight float64) error {
const query = `
INSERT INTO concept_weights (concept, weight, updated_at)
VALUES (?, ?, datetime('now'))
ON CONFLICT(concept) DO UPDATE SET weight = excluded.weight, updated_at = excluded.updated_at
`
_, err := s.store.ExecContext(ctx, query, concept, weight)
return err
}
// UpdateConceptWeights bulk updates multiple concept weights.
func (s *ObservationStore) UpdateConceptWeights(ctx context.Context, weights map[string]float64) error {
if len(weights) == 0 {
return nil
}
tx, err := s.store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO concept_weights (concept, weight, updated_at)
VALUES (?, ?, datetime('now'))
ON CONFLICT(concept) DO UPDATE SET weight = excluded.weight, updated_at = excluded.updated_at
`)
if err != nil {
return err
}
defer stmt.Close()
for concept, weight := range weights {
if _, err := stmt.ExecContext(ctx, concept, weight); err != nil {
return err
}
}
return tx.Commit()
}
// GetObservationFeedbackStats returns statistics about user feedback.
func (s *ObservationStore) GetObservationFeedbackStats(ctx context.Context, project string) (*FeedbackStats, error) {
var query string
var args []interface{}
if project == "" {
query = `
SELECT
COUNT(*) as total,
COALESCE(SUM(CASE WHEN user_feedback = 1 THEN 1 ELSE 0 END), 0) as positive,
COALESCE(SUM(CASE WHEN user_feedback = -1 THEN 1 ELSE 0 END), 0) as negative,
COALESCE(SUM(CASE WHEN user_feedback = 0 THEN 1 ELSE 0 END), 0) as neutral,
COALESCE(AVG(COALESCE(importance_score, 1.0)), 0) as avg_score,
COALESCE(AVG(COALESCE(retrieval_count, 0)), 0) as avg_retrieval
FROM observations
`
} else {
query = `
SELECT
COUNT(*) as total,
COALESCE(SUM(CASE WHEN user_feedback = 1 THEN 1 ELSE 0 END), 0) as positive,
COALESCE(SUM(CASE WHEN user_feedback = -1 THEN 1 ELSE 0 END), 0) as negative,
COALESCE(SUM(CASE WHEN user_feedback = 0 THEN 1 ELSE 0 END), 0) as neutral,
COALESCE(AVG(COALESCE(importance_score, 1.0)), 0) as avg_score,
COALESCE(AVG(COALESCE(retrieval_count, 0)), 0) as avg_retrieval
FROM observations
WHERE project = ? OR scope = 'global'
`
args = append(args, project)
}
var stats FeedbackStats
err := s.store.QueryRowContext(ctx, query, args...).Scan(
&stats.Total,
&stats.Positive,
&stats.Negative,
&stats.Neutral,
&stats.AvgScore,
&stats.AvgRetrieval,
)
if err != nil {
return nil, err
}
return &stats, nil
}
// FeedbackStats contains statistics about observation feedback and scoring.
type FeedbackStats struct {
Total int `json:"total"`
Positive int `json:"positive"`
Negative int `json:"negative"`
Neutral int `json:"neutral"`
AvgScore float64 `json:"avg_score"`
AvgRetrieval float64 `json:"avg_retrieval"`
}
// GetTopScoringObservations returns the highest-scoring observations.
func (s *ObservationStore) GetTopScoringObservations(ctx context.Context, project string, limit int) ([]*models.Observation, error) {
var query string
var args []interface{}
if project == "" {
query = `SELECT ` + observationColumns + `
FROM observations
ORDER BY COALESCE(importance_score, 1.0) DESC, created_at_epoch DESC
LIMIT ?
`
args = append(args, limit)
} else {
query = `SELECT ` + observationColumns + `
FROM observations
WHERE project = ? OR scope = 'global'
ORDER BY COALESCE(importance_score, 1.0) DESC, created_at_epoch DESC
LIMIT ?
`
args = append(args, project, limit)
}
rows, err := s.store.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanObservationRows(rows)
}
// GetMostRetrievedObservations returns the most frequently retrieved observations.
func (s *ObservationStore) GetMostRetrievedObservations(ctx context.Context, project string, limit int) ([]*models.Observation, error) {
var query string
var args []interface{}
if project == "" {
query = `SELECT ` + observationColumns + `
FROM observations
WHERE retrieval_count > 0
ORDER BY retrieval_count DESC, created_at_epoch DESC
LIMIT ?
`
args = append(args, limit)
} else {
query = `SELECT ` + observationColumns + `
FROM observations
WHERE (project = ? OR scope = 'global') AND retrieval_count > 0
ORDER BY retrieval_count DESC, created_at_epoch DESC
LIMIT ?
`
args = append(args, project, limit)
}
rows, err := s.store.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanObservationRows(rows)
}
// ResetObservationScores resets all observation scores to their default values.
// This is useful for testing or when changing the scoring algorithm.
func (s *ObservationStore) ResetObservationScores(ctx context.Context) error {
const query = `
UPDATE observations
SET importance_score = 1.0, score_updated_at_epoch = NULL
`
_, err := s.store.ExecContext(ctx, query)
return err
}
+698
View File
@@ -0,0 +1,698 @@
// Package sqlite provides SQLite database operations for claude-mnemonic.
package sqlite
import (
"context"
"database/sql"
"testing"
"time"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
// testScoringObservationStore creates an ObservationStore with scoring columns for testing.
func testScoringObservationStore(t *testing.T) (*ObservationStore, *Store, func()) {
t.Helper()
db, _, cleanup := testDB(t)
createBaseTables(t, db)
createConceptWeightsTable(t, db)
// Add importance index if not exists (columns already in createBaseTables)
if _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_observations_importance ON observations(importance_score DESC, created_at_epoch DESC)`); err != nil {
t.Fatalf("create importance index: %v", err)
}
store := newStoreFromDB(db)
obsStore := NewObservationStore(store)
return obsStore, store, cleanup
}
// createConceptWeightsTable creates the concept_weights table for testing.
func createConceptWeightsTable(t *testing.T, db *sql.DB) {
t.Helper()
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS concept_weights (
concept TEXT PRIMARY KEY,
weight REAL NOT NULL DEFAULT 0.1,
updated_at TEXT NOT NULL
)
`)
if err != nil {
t.Fatalf("create concept_weights: %v", err)
}
}
// ScoringStoreSuite is a test suite for scoring-related database operations.
type ScoringStoreSuite struct {
suite.Suite
obsStore *ObservationStore
store *Store
cleanup func()
ctx context.Context
}
func (s *ScoringStoreSuite) SetupTest() {
s.obsStore, s.store, s.cleanup = testScoringObservationStore(s.T())
s.ctx = context.Background()
}
func (s *ScoringStoreSuite) TearDownTest() {
if s.cleanup != nil {
s.cleanup()
}
}
func TestScoringStoreSuite(t *testing.T) {
suite.Run(t, new(ScoringStoreSuite))
}
// =============================================================================
// FEEDBACK TESTS
// =============================================================================
func (s *ScoringStoreSuite) TestUpdateObservationFeedback_Positive() {
// Create observation
obs := &models.ParsedObservation{
Type: models.ObsTypeBugfix,
Title: "Test feedback",
}
id, _, err := s.obsStore.StoreObservation(s.ctx, "session-1", "project-a", obs, 1, 100)
s.NoError(err)
// Update feedback to positive
err = s.obsStore.UpdateObservationFeedback(s.ctx, id, 1)
s.NoError(err)
// Verify
retrieved, err := s.obsStore.GetObservationByID(s.ctx, id)
s.NoError(err)
s.Equal(1, retrieved.UserFeedback)
s.True(retrieved.ScoreUpdatedAt.Valid)
}
func (s *ScoringStoreSuite) TestUpdateObservationFeedback_Negative() {
obs := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
}
id, _, err := s.obsStore.StoreObservation(s.ctx, "session-1", "project-a", obs, 1, 100)
s.NoError(err)
err = s.obsStore.UpdateObservationFeedback(s.ctx, id, -1)
s.NoError(err)
retrieved, err := s.obsStore.GetObservationByID(s.ctx, id)
s.NoError(err)
s.Equal(-1, retrieved.UserFeedback)
}
func (s *ScoringStoreSuite) TestUpdateObservationFeedback_Neutral() {
obs := &models.ParsedObservation{
Type: models.ObsTypeChange,
}
id, _, err := s.obsStore.StoreObservation(s.ctx, "session-1", "project-a", obs, 1, 100)
s.NoError(err)
// First set to positive
err = s.obsStore.UpdateObservationFeedback(s.ctx, id, 1)
s.NoError(err)
// Then reset to neutral
err = s.obsStore.UpdateObservationFeedback(s.ctx, id, 0)
s.NoError(err)
retrieved, err := s.obsStore.GetObservationByID(s.ctx, id)
s.NoError(err)
s.Equal(0, retrieved.UserFeedback)
}
func (s *ScoringStoreSuite) TestUpdateObservationFeedback_NonExistent() {
// Updating non-existent observation should not fail (just no rows affected)
err := s.obsStore.UpdateObservationFeedback(s.ctx, 99999, 1)
s.NoError(err)
}
// =============================================================================
// RETRIEVAL COUNT TESTS
// =============================================================================
func (s *ScoringStoreSuite) TestIncrementRetrievalCount_Single() {
obs := &models.ParsedObservation{
Type: models.ObsTypeBugfix,
}
id, _, err := s.obsStore.StoreObservation(s.ctx, "session-1", "project-a", obs, 1, 100)
s.NoError(err)
err = s.obsStore.IncrementRetrievalCount(s.ctx, []int64{id})
s.NoError(err)
retrieved, err := s.obsStore.GetObservationByID(s.ctx, id)
s.NoError(err)
s.Equal(1, retrieved.RetrievalCount)
s.True(retrieved.LastRetrievedAt.Valid)
}
func (s *ScoringStoreSuite) TestIncrementRetrievalCount_Multiple() {
var ids []int64
for i := 0; i < 3; i++ {
obs := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
}
id, _, err := s.obsStore.StoreObservation(s.ctx, "session-1", "project-a", obs, i, 100)
s.NoError(err)
ids = append(ids, id)
}
err := s.obsStore.IncrementRetrievalCount(s.ctx, ids)
s.NoError(err)
for _, id := range ids {
retrieved, err := s.obsStore.GetObservationByID(s.ctx, id)
s.NoError(err)
s.Equal(1, retrieved.RetrievalCount)
}
}
func (s *ScoringStoreSuite) TestIncrementRetrievalCount_Cumulative() {
obs := &models.ParsedObservation{
Type: models.ObsTypeBugfix,
}
id, _, err := s.obsStore.StoreObservation(s.ctx, "session-1", "project-a", obs, 1, 100)
s.NoError(err)
// Increment multiple times
for i := 0; i < 5; i++ {
err = s.obsStore.IncrementRetrievalCount(s.ctx, []int64{id})
s.NoError(err)
}
retrieved, err := s.obsStore.GetObservationByID(s.ctx, id)
s.NoError(err)
s.Equal(5, retrieved.RetrievalCount)
}
func (s *ScoringStoreSuite) TestIncrementRetrievalCount_Empty() {
err := s.obsStore.IncrementRetrievalCount(s.ctx, []int64{})
s.NoError(err)
}
// =============================================================================
// IMPORTANCE SCORE TESTS
// =============================================================================
func (s *ScoringStoreSuite) TestUpdateImportanceScore_Single() {
obs := &models.ParsedObservation{
Type: models.ObsTypeBugfix,
}
id, _, err := s.obsStore.StoreObservation(s.ctx, "session-1", "project-a", obs, 1, 100)
s.NoError(err)
err = s.obsStore.UpdateImportanceScore(s.ctx, id, 1.5)
s.NoError(err)
retrieved, err := s.obsStore.GetObservationByID(s.ctx, id)
s.NoError(err)
s.InDelta(1.5, retrieved.ImportanceScore, 0.001)
}
func (s *ScoringStoreSuite) TestUpdateImportanceScores_Batch() {
var ids []int64
for i := 0; i < 5; i++ {
obs := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
}
id, _, err := s.obsStore.StoreObservation(s.ctx, "session-1", "project-a", obs, i, 100)
s.NoError(err)
ids = append(ids, id)
}
scores := map[int64]float64{
ids[0]: 1.5,
ids[1]: 0.8,
ids[2]: 1.2,
ids[3]: 0.5,
ids[4]: 2.0,
}
err := s.obsStore.UpdateImportanceScores(s.ctx, scores)
s.NoError(err)
for id, expectedScore := range scores {
retrieved, err := s.obsStore.GetObservationByID(s.ctx, id)
s.NoError(err)
s.InDelta(expectedScore, retrieved.ImportanceScore, 0.001)
}
}
func (s *ScoringStoreSuite) TestUpdateImportanceScores_Empty() {
err := s.obsStore.UpdateImportanceScores(s.ctx, map[int64]float64{})
s.NoError(err)
}
// =============================================================================
// OBSERVATIONS NEEDING SCORE UPDATE TESTS
// =============================================================================
func (s *ScoringStoreSuite) TestGetObservationsNeedingScoreUpdate_NeverUpdated() {
// Observations without score_updated_at_epoch should need update
obs := &models.ParsedObservation{
Type: models.ObsTypeBugfix,
}
id, _, err := s.obsStore.StoreObservation(s.ctx, "session-1", "project-a", obs, 1, 100)
s.NoError(err)
observations, err := s.obsStore.GetObservationsNeedingScoreUpdate(s.ctx, 6*time.Hour, 100)
s.NoError(err)
s.Len(observations, 1)
s.Equal(id, observations[0].ID)
}
func (s *ScoringStoreSuite) TestGetObservationsNeedingScoreUpdate_RecentlyUpdated() {
obs := &models.ParsedObservation{
Type: models.ObsTypeBugfix,
}
id, _, err := s.obsStore.StoreObservation(s.ctx, "session-1", "project-a", obs, 1, 100)
s.NoError(err)
// Update score (this sets score_updated_at_epoch)
err = s.obsStore.UpdateImportanceScore(s.ctx, id, 1.5)
s.NoError(err)
// Should not need update (just updated)
observations, err := s.obsStore.GetObservationsNeedingScoreUpdate(s.ctx, 6*time.Hour, 100)
s.NoError(err)
s.Empty(observations)
}
func (s *ScoringStoreSuite) TestGetObservationsNeedingScoreUpdate_Limit() {
// Create 10 observations
for i := 0; i < 10; i++ {
obs := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
}
_, _, err := s.obsStore.StoreObservation(s.ctx, "session-1", "project-a", obs, i, 100)
s.NoError(err)
}
// Request only 5
observations, err := s.obsStore.GetObservationsNeedingScoreUpdate(s.ctx, 6*time.Hour, 5)
s.NoError(err)
s.Len(observations, 5)
}
// =============================================================================
// CONCEPT WEIGHTS TESTS
// =============================================================================
func (s *ScoringStoreSuite) TestGetConceptWeights_Empty() {
weights, err := s.obsStore.GetConceptWeights(s.ctx)
s.NoError(err)
s.Equal(models.DefaultConceptWeights, weights)
}
func (s *ScoringStoreSuite) TestUpdateConceptWeight_NewConcept() {
err := s.obsStore.UpdateConceptWeight(s.ctx, "new-concept", 0.42)
s.NoError(err)
weights, err := s.obsStore.GetConceptWeights(s.ctx)
s.NoError(err)
s.Equal(0.42, weights["new-concept"])
}
func (s *ScoringStoreSuite) TestUpdateConceptWeight_UpdateExisting() {
// Insert first
err := s.obsStore.UpdateConceptWeight(s.ctx, "test-concept", 0.1)
s.NoError(err)
// Update
err = s.obsStore.UpdateConceptWeight(s.ctx, "test-concept", 0.9)
s.NoError(err)
weights, err := s.obsStore.GetConceptWeights(s.ctx)
s.NoError(err)
s.Equal(0.9, weights["test-concept"])
}
func (s *ScoringStoreSuite) TestUpdateConceptWeights_Batch() {
weightsToSet := map[string]float64{
"security": 0.5,
"performance": 0.3,
"testing": 0.2,
}
err := s.obsStore.UpdateConceptWeights(s.ctx, weightsToSet)
s.NoError(err)
retrieved, err := s.obsStore.GetConceptWeights(s.ctx)
s.NoError(err)
for concept, expected := range weightsToSet {
s.Equal(expected, retrieved[concept])
}
}
func (s *ScoringStoreSuite) TestUpdateConceptWeights_Empty() {
err := s.obsStore.UpdateConceptWeights(s.ctx, map[string]float64{})
s.NoError(err)
}
// =============================================================================
// FEEDBACK STATS TESTS
// =============================================================================
func (s *ScoringStoreSuite) TestGetObservationFeedbackStats_Empty() {
stats, err := s.obsStore.GetObservationFeedbackStats(s.ctx, "")
s.NoError(err)
s.Equal(0, stats.Total)
s.Equal(0, stats.Positive)
s.Equal(0, stats.Negative)
s.Equal(0, stats.Neutral)
}
func (s *ScoringStoreSuite) TestGetObservationFeedbackStats_WithData() {
// Create observations with different feedback
feedbacks := []int{1, 1, 1, -1, -1, 0, 0, 0, 0, 0}
for i, fb := range feedbacks {
obs := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
}
id, _, err := s.obsStore.StoreObservation(s.ctx, "session-1", "project-a", obs, i, 100)
s.NoError(err)
if fb != 0 {
err = s.obsStore.UpdateObservationFeedback(s.ctx, id, fb)
s.NoError(err)
}
}
stats, err := s.obsStore.GetObservationFeedbackStats(s.ctx, "")
s.NoError(err)
s.Equal(10, stats.Total)
s.Equal(3, stats.Positive)
s.Equal(2, stats.Negative)
s.Equal(5, stats.Neutral)
}
func (s *ScoringStoreSuite) TestGetObservationFeedbackStats_ByProject() {
// Project A observations
for i := 0; i < 5; i++ {
obs := &models.ParsedObservation{
Type: models.ObsTypeBugfix,
}
id, _, err := s.obsStore.StoreObservation(s.ctx, "session-1", "project-a", obs, i, 100)
s.NoError(err)
_ = s.obsStore.UpdateObservationFeedback(s.ctx, id, 1)
}
// Project B observations
for i := 0; i < 3; i++ {
obs := &models.ParsedObservation{
Type: models.ObsTypeFeature,
}
id, _, err := s.obsStore.StoreObservation(s.ctx, "session-1", "project-b", obs, i, 100)
s.NoError(err)
_ = s.obsStore.UpdateObservationFeedback(s.ctx, id, -1)
}
// Check project A stats
statsA, err := s.obsStore.GetObservationFeedbackStats(s.ctx, "project-a")
s.NoError(err)
s.Equal(5, statsA.Total)
s.Equal(5, statsA.Positive)
// Check project B stats
statsB, err := s.obsStore.GetObservationFeedbackStats(s.ctx, "project-b")
s.NoError(err)
s.Equal(3, statsB.Total)
s.Equal(3, statsB.Negative)
}
// =============================================================================
// TOP SCORING OBSERVATIONS TESTS
// =============================================================================
func (s *ScoringStoreSuite) TestGetTopScoringObservations() {
// Create observations with different scores
for i := 0; i < 5; i++ {
obs := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
}
id, _, err := s.obsStore.StoreObservation(s.ctx, "session-1", "project-a", obs, i, 100)
s.NoError(err)
// Set different scores
err = s.obsStore.UpdateImportanceScore(s.ctx, id, float64(i+1)*0.5)
s.NoError(err)
}
// Get top 3
top, err := s.obsStore.GetTopScoringObservations(s.ctx, "", 3)
s.NoError(err)
s.Len(top, 3)
// Verify ordered by score descending
s.GreaterOrEqual(top[0].ImportanceScore, top[1].ImportanceScore)
s.GreaterOrEqual(top[1].ImportanceScore, top[2].ImportanceScore)
}
func (s *ScoringStoreSuite) TestGetTopScoringObservations_ByProject() {
// Project A with high scores
for i := 0; i < 3; i++ {
obs := &models.ParsedObservation{
Type: models.ObsTypeBugfix,
}
id, _, err := s.obsStore.StoreObservation(s.ctx, "session-1", "project-a", obs, i, 100)
s.NoError(err)
_ = s.obsStore.UpdateImportanceScore(s.ctx, id, 2.0)
}
// Project B with low scores
for i := 0; i < 3; i++ {
obs := &models.ParsedObservation{
Type: models.ObsTypeChange,
}
id, _, err := s.obsStore.StoreObservation(s.ctx, "session-1", "project-b", obs, i, 100)
s.NoError(err)
_ = s.obsStore.UpdateImportanceScore(s.ctx, id, 0.5)
}
// Get top for project A
topA, err := s.obsStore.GetTopScoringObservations(s.ctx, "project-a", 10)
s.NoError(err)
s.Len(topA, 3)
for _, obs := range topA {
s.Equal("project-a", obs.Project)
}
}
// =============================================================================
// MOST RETRIEVED OBSERVATIONS TESTS
// =============================================================================
func (s *ScoringStoreSuite) TestGetMostRetrievedObservations() {
// Create observations with different retrieval counts
var ids []int64
for i := 0; i < 5; i++ {
obs := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
}
id, _, err := s.obsStore.StoreObservation(s.ctx, "session-1", "project-a", obs, i, 100)
s.NoError(err)
ids = append(ids, id)
}
// Set different retrieval counts
for i := 0; i < 10; i++ {
_ = s.obsStore.IncrementRetrievalCount(s.ctx, []int64{ids[0]}) // 10 retrievals
}
for i := 0; i < 5; i++ {
_ = s.obsStore.IncrementRetrievalCount(s.ctx, []int64{ids[1]}) // 5 retrievals
}
for i := 0; i < 3; i++ {
_ = s.obsStore.IncrementRetrievalCount(s.ctx, []int64{ids[2]}) // 3 retrievals
}
// ids[3] and ids[4] have 0 retrievals
// Get top 3
most, err := s.obsStore.GetMostRetrievedObservations(s.ctx, "", 3)
s.NoError(err)
s.Len(most, 3)
// Verify ordered by retrieval count descending
s.Equal(10, most[0].RetrievalCount)
s.Equal(5, most[1].RetrievalCount)
s.Equal(3, most[2].RetrievalCount)
}
func (s *ScoringStoreSuite) TestGetMostRetrievedObservations_NoRetrievals() {
// Create observations without any retrievals
obs := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
}
_, _, err := s.obsStore.StoreObservation(s.ctx, "session-1", "project-a", obs, 1, 100)
s.NoError(err)
most, err := s.obsStore.GetMostRetrievedObservations(s.ctx, "", 10)
s.NoError(err)
s.Empty(most) // No observations with retrieval_count > 0
}
// =============================================================================
// RESET OBSERVATION SCORES TESTS
// =============================================================================
func (s *ScoringStoreSuite) TestResetObservationScores() {
// Create observations with various scores
for i := 0; i < 5; i++ {
obs := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
}
id, _, err := s.obsStore.StoreObservation(s.ctx, "session-1", "project-a", obs, i, 100)
s.NoError(err)
_ = s.obsStore.UpdateImportanceScore(s.ctx, id, float64(i+1))
}
// Reset all scores
err := s.obsStore.ResetObservationScores(s.ctx)
s.NoError(err)
// Verify all scores are reset to 1.0
observations, err := s.obsStore.GetAllRecentObservations(s.ctx, 100)
s.NoError(err)
for _, obs := range observations {
s.InDelta(1.0, obs.ImportanceScore, 0.001)
s.False(obs.ScoreUpdatedAt.Valid)
}
}
// =============================================================================
// EDGE CASES
// =============================================================================
func (s *ScoringStoreSuite) TestScoring_ZeroScore() {
obs := &models.ParsedObservation{
Type: models.ObsTypeBugfix,
}
id, _, err := s.obsStore.StoreObservation(s.ctx, "session-1", "project-a", obs, 1, 100)
s.NoError(err)
// Set score to 0
err = s.obsStore.UpdateImportanceScore(s.ctx, id, 0.0)
s.NoError(err)
retrieved, err := s.obsStore.GetObservationByID(s.ctx, id)
s.NoError(err)
s.InDelta(0.0, retrieved.ImportanceScore, 0.001)
}
func (s *ScoringStoreSuite) TestScoring_NegativeScore() {
obs := &models.ParsedObservation{
Type: models.ObsTypeBugfix,
}
id, _, err := s.obsStore.StoreObservation(s.ctx, "session-1", "project-a", obs, 1, 100)
s.NoError(err)
// Set negative score (calculator shouldn't produce this, but test DB handling)
err = s.obsStore.UpdateImportanceScore(s.ctx, id, -0.5)
s.NoError(err)
retrieved, err := s.obsStore.GetObservationByID(s.ctx, id)
s.NoError(err)
s.InDelta(-0.5, retrieved.ImportanceScore, 0.001)
}
func (s *ScoringStoreSuite) TestScoring_LargeScore() {
obs := &models.ParsedObservation{
Type: models.ObsTypeBugfix,
}
id, _, err := s.obsStore.StoreObservation(s.ctx, "session-1", "project-a", obs, 1, 100)
s.NoError(err)
// Set very large score
err = s.obsStore.UpdateImportanceScore(s.ctx, id, 999.999)
s.NoError(err)
retrieved, err := s.obsStore.GetObservationByID(s.ctx, id)
s.NoError(err)
s.InDelta(999.999, retrieved.ImportanceScore, 0.001)
}
func (s *ScoringStoreSuite) TestConceptWeight_ZeroWeight() {
err := s.obsStore.UpdateConceptWeight(s.ctx, "zero-concept", 0.0)
s.NoError(err)
weights, err := s.obsStore.GetConceptWeights(s.ctx)
s.NoError(err)
s.Equal(0.0, weights["zero-concept"])
}
func (s *ScoringStoreSuite) TestConceptWeight_ExactBoundary() {
err := s.obsStore.UpdateConceptWeight(s.ctx, "max-concept", 1.0)
s.NoError(err)
weights, err := s.obsStore.GetConceptWeights(s.ctx)
s.NoError(err)
s.Equal(1.0, weights["max-concept"])
}
// =============================================================================
// STANDALONE TESTS
// =============================================================================
func TestFeedbackStats_Structure(t *testing.T) {
stats := FeedbackStats{
Total: 100,
Positive: 30,
Negative: 10,
Neutral: 60,
AvgScore: 1.5,
AvgRetrieval: 5.0,
}
assert.Equal(t, 100, stats.Total)
assert.Equal(t, 30, stats.Positive)
assert.Equal(t, 10, stats.Negative)
assert.Equal(t, 60, stats.Neutral)
assert.Equal(t, 1.5, stats.AvgScore)
assert.Equal(t, 5.0, stats.AvgRetrieval)
}
func TestScoringStore_Integration(t *testing.T) {
obsStore, _, cleanup := testScoringObservationStore(t)
defer cleanup()
ctx := context.Background()
// Full integration test: store, feedback, retrieval, score update
obs := &models.ParsedObservation{
Type: models.ObsTypeBugfix,
Title: "Integration test observation",
Concepts: []string{"security"},
}
id, _, err := obsStore.StoreObservation(ctx, "session-int", "project-int", obs, 1, 100)
require.NoError(t, err)
// Add feedback
err = obsStore.UpdateObservationFeedback(ctx, id, 1)
require.NoError(t, err)
// Increment retrieval
err = obsStore.IncrementRetrievalCount(ctx, []int64{id})
require.NoError(t, err)
// Update score
err = obsStore.UpdateImportanceScore(ctx, id, 1.75)
require.NoError(t, err)
// Verify final state
retrieved, err := obsStore.GetObservationByID(ctx, id)
require.NoError(t, err)
assert.Equal(t, 1, retrieved.UserFeedback)
assert.Equal(t, 1, retrieved.RetrievalCount)
assert.InDelta(t, 1.75, retrieved.ImportanceScore, 0.001)
assert.True(t, retrieved.ScoreUpdatedAt.Valid)
assert.True(t, retrieved.LastRetrievedAt.Valid)
}
+18
View File
@@ -116,3 +116,21 @@ func (s *SummaryStore) GetAllRecentSummaries(ctx context.Context, limit int) ([]
return scanSummaryRows(rows)
}
// GetAllSummaries retrieves all summaries (for vector rebuild).
func (s *SummaryStore) GetAllSummaries(ctx context.Context) ([]*models.SessionSummary, error) {
const query = `
SELECT id, sdk_session_id, project, request, investigated, learned, completed,
next_steps, notes, prompt_number, discovery_tokens, created_at, created_at_epoch
FROM session_summaries
ORDER BY id
`
rows, err := s.store.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
return scanSummaryRows(rows)
}
+52
View File
@@ -103,6 +103,12 @@ func createBaseTables(t *testing.T, db *sql.DB) {
discovery_tokens INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
importance_score REAL DEFAULT 1.0,
user_feedback INTEGER DEFAULT 0,
retrieval_count INTEGER DEFAULT 0,
last_retrieved_at_epoch INTEGER,
score_updated_at_epoch INTEGER,
is_superseded INTEGER DEFAULT 0,
FOREIGN KEY(sdk_session_id) REFERENCES sdk_sessions(sdk_session_id) ON DELETE CASCADE
)
`)
@@ -110,6 +116,27 @@ func createBaseTables(t *testing.T, db *sql.DB) {
t.Fatalf("create observations: %v", err)
}
// Create observation_conflicts table for conflict detection
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS observation_conflicts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
newer_obs_id INTEGER NOT NULL,
older_obs_id INTEGER NOT NULL,
conflict_type TEXT NOT NULL CHECK(conflict_type IN ('superseded', 'contradicts', 'outdated_pattern')),
resolution TEXT NOT NULL CHECK(resolution IN ('prefer_newer', 'prefer_older', 'manual')),
reason TEXT,
detected_at TEXT NOT NULL,
detected_at_epoch INTEGER NOT NULL,
resolved INTEGER DEFAULT 0,
resolved_at TEXT,
FOREIGN KEY(newer_obs_id) REFERENCES observations(id) ON DELETE CASCADE,
FOREIGN KEY(older_obs_id) REFERENCES observations(id) ON DELETE CASCADE
)
`)
if err != nil {
t.Fatalf("create observation_conflicts: %v", err)
}
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS session_summaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -150,6 +177,31 @@ func createBaseTables(t *testing.T, db *sql.DB) {
t.Fatalf("create user_prompts: %v", err)
}
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS patterns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('bug', 'refactor', 'architecture', 'anti-pattern', 'best-practice')),
description TEXT,
signature TEXT,
recommendation TEXT,
frequency INTEGER DEFAULT 1,
projects TEXT,
observation_ids TEXT,
status TEXT DEFAULT 'active' CHECK(status IN ('active', 'deprecated', 'merged')),
merged_into_id INTEGER,
confidence REAL DEFAULT 0.5,
last_seen_at TEXT NOT NULL,
last_seen_at_epoch INTEGER NOT NULL,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
FOREIGN KEY(merged_into_id) REFERENCES patterns(id) ON DELETE SET NULL
)
`)
if err != nil {
t.Fatalf("create patterns: %v", err)
}
indexes := []string{
`CREATE INDEX IF NOT EXISTS idx_sdk_sessions_claude_id ON sdk_sessions(claude_session_id)`,
`CREATE INDEX IF NOT EXISTS idx_sdk_sessions_sdk_id ON sdk_sessions(sdk_session_id)`,