mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-06-05 23:03:55 +00:00
f79782a008
* 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.
325 lines
9.1 KiB
Go
325 lines
9.1 KiB
Go
// 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
|
|
}
|