Files
claude-mnemonic/internal/worker/handlers_data.go
T
0845aeb58a fix(db): keep archived notes out of search, lists, counts and the vector rebuild (#74)
* fix(db): keep archived notes out of search, lists, counts and the vector rebuild

An archived note is meant to be kept but unused, yet most read paths did not
filter on is_archived: full-text and LIKE search, vector-hit hydration,
context injection, the paginated lists, counts and the vector rebuild all still
returned it. A vector hit for an archived note also still pointed at a live
embedding, because archiving never dropped it.

- add notArchivedFilter/visibleScope and apply them to every read path
- the paginated lists take includeArchived; the API reads
  ?include_archived=true, and exports ask for them so a backup is complete
- archiving drops the note's vectors, unarchiving syncs them back
- add //go:build fts5 to observation_update_test.go so `go vet` without tags
  builds (it referenced a helper from a tagged file)

Adds a store test that archives one of three notes and checks every path, and
that fails if the filter is removed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* fix(worker): drop the vectors of notes archived through bulk-status too

POST /api/observations/bulk-status with action "archive" archived notes but left
their vectors in the index; only the dedicated archive handler dropped them.
Both paths now call dropArchivedFromVectors.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

---------

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-13 09:34:58 +01:00

726 lines
22 KiB
Go

// Package worker provides data retrieval HTTP handlers.
package worker
import (
"context"
"encoding/json"
"net/http"
"runtime"
"sort"
"strings"
"time"
"github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm"
"github.com/lukaszraczylo/claude-mnemonic/internal/embedding"
"github.com/lukaszraczylo/claude-mnemonic/internal/vector/sqlitevec"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/rs/zerolog/log"
)
// handleGetObservations returns recent observations.
// Supports optional query parameter for semantic search via sqlite-vec.
// Supports pagination via limit and offset query parameters.
func (s *Service) handleGetObservations(w http.ResponseWriter, r *http.Request) {
// Add request-scoped timeout to prevent indefinite blocking on DB operations
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
pagination := gorm.ParsePaginationParams(r, DefaultObservationsLimit)
project := r.URL.Query().Get("project")
query := r.URL.Query().Get("query")
// Archived notes are not listed unless asked for.
includeArchived := r.URL.Query().Get("include_archived") == "true"
// Validate project name to prevent path traversal
if err := ValidateProjectName(project); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var observations []*models.Observation
var total int64
var err error
var usedVector bool
// Use vector search if query is provided and vector client is available
if query != "" && s.vectorClient != nil && s.vectorClient.IsConnected() {
where := sqlitevec.BuildWhereFilter(sqlitevec.DocTypeObservation, "")
vectorResults, vecErr := s.vectorClient.Query(ctx, query, pagination.Limit*2, where)
if vecErr == nil && len(vectorResults) > 0 {
obsIDs := sqlitevec.ExtractObservationIDs(vectorResults, project)
if len(obsIDs) > 0 {
observations, err = s.observationStore.GetObservationsByIDs(ctx, obsIDs, "date_desc", pagination.Limit)
if err == nil {
usedVector = true
total = int64(len(observations)) // Vector search doesn't have total, use returned count
}
}
}
}
// Fall back to SQLite if vector search not used
if !usedVector {
if project != "" {
// Strict project filtering for dashboard - only observations from this project
observations, total, err = s.observationStore.GetObservationsByProjectStrictPaginated(ctx, project, pagination.Limit, pagination.Offset, includeArchived)
} else {
// All projects
observations, total, err = s.observationStore.GetAllRecentObservationsPaginated(ctx, pagination.Limit, pagination.Offset, includeArchived)
}
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Ensure we return empty array, not null
if observations == nil {
observations = []*models.Observation{}
}
// Track search if query was provided
if query != "" {
s.trackSearchQuery(query, project, "observations", len(observations), usedVector)
}
// Return paginated response
writeJSON(w, map[string]any{
"observations": observations,
"total": total,
"limit": pagination.Limit,
"offset": pagination.Offset,
"hasMore": int64(pagination.Offset)+int64(len(observations)) < total,
})
}
// handleGetSummaries returns recent summaries.
// Supports optional query parameter for semantic search via sqlite-vec.
func (s *Service) handleGetSummaries(w http.ResponseWriter, r *http.Request) {
// Add request-scoped timeout to prevent indefinite blocking on DB operations
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
limit := gorm.ParseLimitParam(r, DefaultSummariesLimit)
project := r.URL.Query().Get("project")
query := r.URL.Query().Get("query")
// Validate project name to prevent path traversal
if err := ValidateProjectName(project); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var summaries []*models.SessionSummary
var err error
var usedVector bool
// Use vector search if query is provided and vector client is available
if query != "" && s.vectorClient != nil && s.vectorClient.IsConnected() {
where := sqlitevec.BuildWhereFilter(sqlitevec.DocTypeSessionSummary, "")
vectorResults, vecErr := s.vectorClient.Query(ctx, query, limit*2, where)
if vecErr == nil && len(vectorResults) > 0 {
summaryIDs := sqlitevec.ExtractSummaryIDs(vectorResults, project)
if len(summaryIDs) > 0 {
summaries, err = s.summaryStore.GetSummariesByIDs(ctx, summaryIDs, "date_desc", limit)
if err == nil {
usedVector = true
}
}
}
}
// Fall back to SQLite if vector search not used
if !usedVector {
if project != "" {
summaries, err = s.summaryStore.GetRecentSummaries(ctx, project, limit)
} else {
summaries, err = s.summaryStore.GetAllRecentSummaries(ctx, limit)
}
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Ensure we return empty array, not null
if summaries == nil {
summaries = []*models.SessionSummary{}
}
writeJSON(w, summaries)
}
// handleGetPrompts returns recent user prompts.
// Supports optional query parameter for semantic search via sqlite-vec.
func (s *Service) handleGetPrompts(w http.ResponseWriter, r *http.Request) {
// Add request-scoped timeout to prevent indefinite blocking on DB operations
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
limit := gorm.ParseLimitParam(r, DefaultPromptsLimit)
project := r.URL.Query().Get("project")
query := r.URL.Query().Get("query")
// Validate project name to prevent path traversal
if err := ValidateProjectName(project); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var prompts []*models.UserPromptWithSession
var err error
var usedVector bool
// Use vector search if query is provided and vector client is available
if query != "" && s.vectorClient != nil && s.vectorClient.IsConnected() {
where := sqlitevec.BuildWhereFilter(sqlitevec.DocTypeUserPrompt, "")
vectorResults, vecErr := s.vectorClient.Query(ctx, query, limit*2, where)
if vecErr == nil && len(vectorResults) > 0 {
promptIDs := sqlitevec.ExtractPromptIDs(vectorResults, project)
if len(promptIDs) > 0 {
prompts, err = s.promptStore.GetPromptsByIDs(ctx, promptIDs, "date_desc", limit)
if err == nil {
usedVector = true
}
}
}
}
// Fall back to SQLite if vector search not used
if !usedVector {
if project != "" {
prompts, err = s.promptStore.GetRecentUserPromptsByProject(ctx, project, limit)
} else {
prompts, err = s.promptStore.GetAllRecentUserPrompts(ctx, limit)
}
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Ensure we return empty array, not null
if prompts == nil {
prompts = []*models.UserPromptWithSession{}
}
writeJSON(w, prompts)
}
// handleGetProjects returns all projects.
// Response is cacheable for 5 minutes since project list changes infrequently.
func (s *Service) handleGetProjects(w http.ResponseWriter, r *http.Request) {
projects, err := s.sessionStore.GetAllProjects(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Cache for 5 minutes - project list changes infrequently
w.Header().Set("Cache-Control", "public, max-age=300")
writeJSON(w, projects)
}
// handleGetTypes returns the canonical list of observation and concept types.
// This provides a single source of truth for both backend and frontend.
// Response is cacheable as these values never change at runtime.
func (s *Service) handleGetTypes(w http.ResponseWriter, r *http.Request) {
// Cache for 24 hours - these values are compile-time constants
w.Header().Set("Cache-Control", "public, max-age=86400")
writeJSON(w, map[string]any{
"observation_types": ObservationTypes,
"concept_types": ConceptTypes,
})
}
// handleGetModels returns available embedding models.
// Response is cacheable as model list doesn't change without restart.
func (s *Service) handleGetModels(w http.ResponseWriter, _ *http.Request) {
// Cache for 1 hour - model list is static during runtime
w.Header().Set("Cache-Control", "public, max-age=3600")
models := embedding.ListModels()
defaultModel := embedding.GetDefaultModel()
writeJSON(w, map[string]any{
"models": models,
"default": defaultModel,
"current": s.embedSvc.Version(),
})
}
// handleGetStats returns worker statistics.
func (s *Service) handleGetStats(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
// Validate project name to prevent path traversal
if err := ValidateProjectName(project); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
retrievalStats := s.GetRetrievalStats(project)
sessionsToday, _ := s.sessionStore.GetSessionsToday(r.Context())
response := map[string]any{
"uptime": time.Since(s.startTime).String(),
"uptimeSeconds": time.Since(s.startTime).Seconds(),
"activeSessions": s.sessionManager.GetActiveSessionCount(),
"queueDepth": s.sessionManager.GetTotalQueueDepth(),
"isProcessing": s.sessionManager.IsAnySessionProcessing(),
"connectedClients": s.sseBroadcaster.ClientCount(),
"sessionsToday": sessionsToday,
"retrieval": retrievalStats,
"ready": s.ready.Load(),
}
// Add memory stats
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
response["memory"] = map[string]any{
"alloc_mb": float64(memStats.Alloc) / 1024 / 1024,
"total_alloc_mb": float64(memStats.TotalAlloc) / 1024 / 1024,
"sys_mb": float64(memStats.Sys) / 1024 / 1024,
"heap_alloc_mb": float64(memStats.HeapAlloc) / 1024 / 1024,
"heap_inuse_mb": float64(memStats.HeapInuse) / 1024 / 1024,
"heap_objects": memStats.HeapObjects,
"goroutines": runtime.NumGoroutine(),
"gc_cycles": memStats.NumGC,
"gc_pause_total_ms": float64(memStats.PauseTotalNs) / 1e6,
}
// Add database health if available
if s.store != nil {
dbHealth := s.store.HealthCheck(r.Context())
response["database"] = map[string]any{
"status": dbHealth.Status,
"query_latency_ms": float64(dbHealth.QueryLatency) / 1e6,
"pool": dbHealth.PoolStats,
"warning": dbHealth.Warning,
}
}
// Add embedding model info
if s.embedSvc != nil {
response["embeddingModel"] = map[string]any{
"name": s.embedSvc.Name(),
"version": s.embedSvc.Version(),
"dimensions": s.embedSvc.Dimensions(),
}
}
// Add vector cache stats
if s.vectorClient != nil {
if count, err := s.vectorClient.Count(r.Context()); err == nil {
response["vectorCount"] = count
}
cacheSize, cacheMax := s.vectorClient.CacheStats()
response["vectorCache"] = map[string]any{
"size": cacheSize,
"max_size": cacheMax,
}
}
// Include project-specific observation count if project is specified
if project != "" {
count, err := s.getCachedObservationCount(r.Context(), project)
if err == nil {
response["projectObservations"] = count
response["project"] = project
}
}
// Add rate limiter stats
if s.rateLimiter != nil {
response["rateLimiter"] = s.rateLimiter.Stats()
}
// Add circuit breaker metrics
if s.processor != nil {
response["circuitBreaker"] = s.processor.CircuitBreakerMetrics()
}
writeJSON(w, response)
}
// handleGetRetrievalStats returns detailed retrieval statistics.
func (s *Service) handleGetRetrievalStats(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
stats := s.GetRetrievalStats(project)
writeJSON(w, stats)
}
// handleGetRecentQueries returns recent search queries for analytics.
func (s *Service) handleGetRecentQueries(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
limit := gorm.ParseLimitParam(r, 20)
queries := s.getRecentSearchQueries(project, limit)
writeJSON(w, map[string]any{
"queries": queries,
"count": len(queries),
"project": project,
})
}
// handleGetSearchAnalytics returns comprehensive search analytics and statistics.
func (s *Service) handleGetSearchAnalytics(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
// Get all recent queries for analysis
queries := s.getRecentSearchQueries(project, maxRecentQueries)
// Calculate analytics
totalQueries := len(queries)
vectorSearches := 0
totalResults := 0
zeroResultQueries := 0
queryTypes := make(map[string]int)
topKeywords := make(map[string]int)
for _, q := range queries {
if q.UsedVector {
vectorSearches++
}
totalResults += q.Results
if q.Results == 0 {
zeroResultQueries++
}
queryTypes[q.Type]++
// Extract keywords (simple word tokenization using iterator)
for word := range strings.FieldsSeq(strings.ToLower(q.Query)) {
if len(word) > 3 { // Skip short words
topKeywords[word]++
}
}
}
// Sort keywords by frequency
type keywordCount struct {
Keyword string `json:"keyword"`
Count int `json:"count"`
}
sortedKeywords := make([]keywordCount, 0, len(topKeywords))
for kw, count := range topKeywords {
sortedKeywords = append(sortedKeywords, keywordCount{Keyword: kw, Count: count})
}
sort.Slice(sortedKeywords, func(i, j int) bool {
return sortedKeywords[i].Count > sortedKeywords[j].Count
})
if len(sortedKeywords) > 10 {
sortedKeywords = sortedKeywords[:10]
}
// Calculate averages
avgResults := float64(0)
vectorSearchRate := float64(0)
zeroResultRate := float64(0)
if totalQueries > 0 {
avgResults = float64(totalResults) / float64(totalQueries)
vectorSearchRate = float64(vectorSearches) / float64(totalQueries) * 100
zeroResultRate = float64(zeroResultQueries) / float64(totalQueries) * 100
}
writeJSON(w, map[string]any{
"total_queries": totalQueries,
"vector_search_rate": vectorSearchRate,
"avg_results": avgResults,
"zero_result_rate": zeroResultRate,
"query_types": queryTypes,
"top_keywords": sortedKeywords,
"project": project,
})
}
// handleVectorHealth returns comprehensive health information about the vector database.
func (s *Service) handleVectorHealth(w http.ResponseWriter, r *http.Request) {
if s.vectorClient == nil {
http.Error(w, "vector client not initialized", http.StatusServiceUnavailable)
return
}
stats, err := s.vectorClient.GetHealthStats(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Add additional computed metrics
healthScore := 100.0
var warnings []string
// Penalize for stale vectors
if stats.TotalVectors > 0 {
staleRatio := float64(stats.StaleVectors) / float64(stats.TotalVectors)
if staleRatio > 0 {
healthScore -= staleRatio * 50 // Up to 50 points off for stale vectors
warnings = append(warnings, formatWarning("%.1f%% vectors need rebuild", staleRatio*100))
}
}
// Check cache effectiveness
cacheHitRate := stats.EmbeddingCache.HitRate()
if cacheHitRate < 20 && (stats.EmbeddingCache.EmbeddingHits+stats.EmbeddingCache.EmbeddingMisses) > 100 {
healthScore -= 10
warnings = append(warnings, formatWarning("Low cache hit rate: %.1f%%", cacheHitRate))
}
// Penalize if rebuild is needed
if stats.NeedsRebuild {
healthScore -= 20
warnings = append(warnings, "Vector rebuild recommended: "+stats.RebuildReason)
}
if healthScore < 0 {
healthScore = 0
}
status := "healthy"
if healthScore < 50 {
status = "unhealthy"
} else if healthScore < 80 {
status = "degraded"
}
writeJSON(w, map[string]any{
"status": status,
"health_score": healthScore,
"warnings": warnings,
"stats": stats,
"cache_hit_rate": cacheHitRate,
})
}
// UpdateObservationRequest is the request body for updating an observation.
type UpdateObservationRequest struct {
Title *string `json:"title,omitempty"`
Subtitle *string `json:"subtitle,omitempty"`
Narrative *string `json:"narrative,omitempty"`
Scope *string `json:"scope,omitempty"`
Facts []string `json:"facts,omitempty"`
Concepts []string `json:"concepts,omitempty"`
FilesRead []string `json:"files_read,omitempty"`
FilesModified []string `json:"files_modified,omitempty"`
}
// handleUpdateObservation updates an existing observation.
// PUT /api/observations/{id}
func (s *Service) handleUpdateObservation(w http.ResponseWriter, r *http.Request) {
// Parse observation ID from URL
id, ok := parseIDParam(w, r.PathValue("id"), "observation")
if !ok {
return
}
// Parse request body
var req UpdateObservationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
return
}
// Build update struct - only include fields that were provided
update := &gorm.ObservationUpdate{}
if req.Title != nil {
update.Title = req.Title
}
if req.Subtitle != nil {
update.Subtitle = req.Subtitle
}
if req.Narrative != nil {
update.Narrative = req.Narrative
}
if req.Facts != nil {
update.Facts = &req.Facts
}
if req.Concepts != nil {
update.Concepts = &req.Concepts
}
if req.FilesRead != nil {
update.FilesRead = &req.FilesRead
}
if req.FilesModified != nil {
update.FilesModified = &req.FilesModified
}
if req.Scope != nil {
// Validate scope
if *req.Scope != "project" && *req.Scope != "global" {
http.Error(w, "scope must be 'project' or 'global'", http.StatusBadRequest)
return
}
update.Scope = req.Scope
}
// Update the observation
updatedObs, err := s.observationStore.UpdateObservation(r.Context(), id, update)
if err != nil {
if strings.Contains(err.Error(), "not found") {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
http.Error(w, "failed to update observation: "+err.Error(), http.StatusInternalServerError)
return
}
// Trigger vector resync for the updated observation
if s.vectorSync != nil {
s.asyncVectorSync(func() {
if err := s.vectorSync.SyncObservation(s.ctx, updatedObs); err != nil {
log.Warn().Err(err).Int64("id", id).Msg("Failed to resync observation vectors after update")
}
})
}
// Broadcast update event
s.sseBroadcaster.Broadcast(map[string]any{
"type": "observation_updated",
"id": id,
})
writeJSON(w, map[string]any{
"observation": updatedObs,
"message": "observation updated successfully",
})
}
// handleGetObservationByID returns a single observation by ID.
// GET /api/observations/{id}
func (s *Service) handleGetObservationByID(w http.ResponseWriter, r *http.Request) {
id, ok := parseIDParam(w, r.PathValue("id"), "observation")
if !ok {
return
}
obs, err := s.observationStore.GetObservationByID(r.Context(), id)
if err != nil {
http.Error(w, "failed to get observation: "+err.Error(), http.StatusInternalServerError)
return
}
if obs == nil {
http.Error(w, "observation not found", http.StatusNotFound)
return
}
writeJSON(w, obs)
}
// handleGraphStats returns graph statistics for the dashboard.
// Uses relation data to compute knowledge graph metrics.
func (s *Service) handleGraphStats(w http.ResponseWriter, r *http.Request) {
// Get relation count (edges) - this represents the knowledge graph
edgeCount, err := s.relationStore.GetTotalRelationCount(r.Context())
if err != nil {
edgeCount = 0
}
// Count by relation type
edgeTypes := make(map[string]int)
for _, t := range models.AllRelationTypes {
relations, err := s.relationStore.GetRelationsByType(r.Context(), t, 10000)
if err == nil {
edgeTypes[string(t)] = len(relations)
}
}
// Get unique observation IDs involved in relations (approximate node count)
// For now, use edge count as a proxy - each edge has 2 nodes
nodeCount := 0
if edgeCount > 0 {
// Rough estimate: unique nodes ≈ edges * 1.5 (since nodes can have multiple edges)
nodeCount = int(float64(edgeCount) * 1.5)
}
// Calculate average degree
var avgDegree float64
if nodeCount > 0 {
avgDegree = float64(edgeCount*2) / float64(nodeCount)
}
// Graph is enabled if we have any edges (relations)
enabled := edgeCount > 0
writeJSON(w, map[string]any{
"enabled": enabled,
"nodeCount": nodeCount,
"edgeCount": edgeCount,
"avgDegree": avgDegree,
"maxDegree": 0,
"minDegree": 0,
"medianDegree": 0.0,
"edgeTypes": edgeTypes,
"config": map[string]any{
"maxHops": 2,
"branchFactor": 10,
"edgeWeight": 0.3,
"rebuildIntervalMin": 30,
},
})
}
// handleVectorMetrics returns vector database metrics for the dashboard.
// Returns enabled: false if vector features are not available.
func (s *Service) handleVectorMetrics(w http.ResponseWriter, r *http.Request) {
if s.vectorClient == nil {
writeJSON(w, map[string]any{
"enabled": false,
"message": "Vector database not initialized",
})
return
}
// Get cache stats from vector client
cacheSize, cacheMax := s.vectorClient.CacheStats()
cacheStats := s.vectorClient.GetCacheStats()
count, _ := s.vectorClient.Count(r.Context())
uptime := time.Since(s.startTime).Round(time.Second).String()
// Calculate total queries from cache hits/misses
totalQueries := cacheStats.EmbeddingHits + cacheStats.EmbeddingMisses + cacheStats.ResultHits + cacheStats.ResultMisses
totalHits := cacheStats.EmbeddingHits + cacheStats.ResultHits
totalMisses := cacheStats.EmbeddingMisses + cacheStats.ResultMisses
writeJSON(w, map[string]any{
"enabled": true,
"queries": map[string]any{
"total": totalQueries,
"hubOnly": 0,
"hybrid": 0,
"onDemand": 0,
"graph": 0,
},
"latency": map[string]any{
"avg": "0ms",
"p50": "0ms",
"p95": "0ms",
"p99": "0ms",
"avgHub": "0ms",
"avgRecompute": "0ms",
},
"storage": map[string]any{
"totalDocuments": count,
"hubDocuments": 0,
"storedEmbeddings": count,
"savingsPercent": 0.0,
"recomputedTotal": 0,
},
"cache": map[string]any{
"hits": totalHits,
"misses": totalMisses,
"hitRate": cacheStats.HitRate(),
"size": cacheSize,
"maxSize": cacheMax,
},
"graph": map[string]any{
"traversals": 0,
"avgDepth": 0.0,
},
"uptime": uptime,
})
}