mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-06-05 23:03:55 +00:00
4f4b4ac70f
- [x] Add language-specific chunkers with AST parsing (Go, Python, TypeScript) - [x] Implement chunking manager to dispatch files to appropriate chunkers - [x] Integrate code chunks into vector sync for semantic search - [x] Add tree-sitter dependency for Python/TypeScript parsing - [x] Reorder struct fields for consistency across codebase - [x] Rename error variables to follow Go conventions (err → unmarshalErr, etc.) - [x] Add code chunk metadata to vector documents (language, symbol name, line ranges) - [x] Update worker service to initialize chunking pipeline with all three languages
360 lines
9.9 KiB
Go
360 lines
9.9 KiB
Go
// Package worker provides the main worker service for claude-mnemonic.
|
|
package worker
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// FeedbackRequest represents a user feedback submission.
|
|
type FeedbackRequest struct {
|
|
Feedback int `json:"feedback"` // -1 (thumbs down), 0 (neutral), 1 (thumbs up)
|
|
}
|
|
|
|
// handleObservationFeedback handles user feedback (thumbs up/down) for an observation.
|
|
// POST /api/observations/{id}/feedback
|
|
func (s *Service) handleObservationFeedback(w http.ResponseWriter, r *http.Request) {
|
|
// Parse observation ID
|
|
idStr := chi.URLParam(r, "id")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "invalid observation id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse request body
|
|
var req FeedbackRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Validate feedback value
|
|
if req.Feedback < -1 || req.Feedback > 1 {
|
|
http.Error(w, "feedback must be -1, 0, or 1", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get required components
|
|
s.initMu.RLock()
|
|
observationStore := s.observationStore
|
|
scoreCalculator := s.scoreCalculator
|
|
s.initMu.RUnlock()
|
|
|
|
if observationStore == nil {
|
|
http.Error(w, "service not ready", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
// Update feedback in database
|
|
if err := observationStore.UpdateObservationFeedback(r.Context(), id, req.Feedback); err != nil {
|
|
http.Error(w, "failed to update feedback", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Recalculate score immediately if calculator is available
|
|
var newScore float64
|
|
if scoreCalculator != nil {
|
|
obs, err := observationStore.GetObservationByID(r.Context(), id)
|
|
if err == nil && obs != nil {
|
|
obs.UserFeedback = req.Feedback // Apply the new feedback
|
|
newScore = scoreCalculator.Calculate(obs, time.Now())
|
|
if scoreErr := observationStore.UpdateImportanceScore(r.Context(), id, newScore); scoreErr != nil {
|
|
// Log but don't fail - feedback was recorded
|
|
// Score will be updated on next recalculation cycle
|
|
log.Warn().Err(scoreErr).Int64("id", id).Msg("Failed to update importance score after feedback")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Broadcast update via SSE
|
|
s.sseBroadcaster.Broadcast(map[string]interface{}{
|
|
"type": "observation_feedback",
|
|
"id": id,
|
|
"feedback": req.Feedback,
|
|
"score": newScore,
|
|
})
|
|
|
|
writeJSON(w, map[string]interface{}{
|
|
"status": "ok",
|
|
"id": id,
|
|
"feedback": req.Feedback,
|
|
"score": newScore,
|
|
})
|
|
}
|
|
|
|
// handleGetScoringStats returns scoring statistics and configuration.
|
|
// GET /api/scoring/stats
|
|
func (s *Service) handleGetScoringStats(w http.ResponseWriter, r *http.Request) {
|
|
project := r.URL.Query().Get("project")
|
|
|
|
s.initMu.RLock()
|
|
observationStore := s.observationStore
|
|
recalculator := s.recalculator
|
|
s.initMu.RUnlock()
|
|
|
|
if observationStore == nil {
|
|
http.Error(w, "service not ready", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
// Get feedback statistics
|
|
feedbackStats, err := observationStore.GetObservationFeedbackStats(r.Context(), project)
|
|
if err != nil {
|
|
http.Error(w, "failed to get feedback stats", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"feedback": feedbackStats,
|
|
}
|
|
|
|
// Add recalculator stats if available
|
|
if recalculator != nil {
|
|
response["recalculator"] = recalculator.GetStats()
|
|
}
|
|
|
|
writeJSON(w, response)
|
|
}
|
|
|
|
// handleGetTopObservations returns the highest-scoring observations.
|
|
// GET /api/observations/top
|
|
func (s *Service) handleGetTopObservations(w http.ResponseWriter, r *http.Request) {
|
|
limit := parseIntParam(r, "limit", 10)
|
|
project := r.URL.Query().Get("project")
|
|
|
|
s.initMu.RLock()
|
|
observationStore := s.observationStore
|
|
s.initMu.RUnlock()
|
|
|
|
if observationStore == nil {
|
|
http.Error(w, "service not ready", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
observations, err := observationStore.GetTopScoringObservations(r.Context(), project, limit)
|
|
if err != nil {
|
|
http.Error(w, "failed to get top observations", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if observations == nil {
|
|
observations = []*models.Observation{}
|
|
}
|
|
|
|
writeJSON(w, observations)
|
|
}
|
|
|
|
// handleGetMostRetrieved returns the most frequently retrieved observations.
|
|
// GET /api/observations/most-retrieved
|
|
func (s *Service) handleGetMostRetrieved(w http.ResponseWriter, r *http.Request) {
|
|
limit := parseIntParam(r, "limit", 10)
|
|
project := r.URL.Query().Get("project")
|
|
|
|
s.initMu.RLock()
|
|
observationStore := s.observationStore
|
|
s.initMu.RUnlock()
|
|
|
|
if observationStore == nil {
|
|
http.Error(w, "service not ready", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
observations, err := observationStore.GetMostRetrievedObservations(r.Context(), project, limit)
|
|
if err != nil {
|
|
http.Error(w, "failed to get most retrieved observations", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if observations == nil {
|
|
observations = []*models.Observation{}
|
|
}
|
|
|
|
writeJSON(w, observations)
|
|
}
|
|
|
|
// handleExplainScore returns a breakdown of how an observation's score was calculated.
|
|
// GET /api/observations/{id}/score
|
|
func (s *Service) handleExplainScore(w http.ResponseWriter, r *http.Request) {
|
|
// Parse observation ID
|
|
idStr := chi.URLParam(r, "id")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "invalid observation id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
s.initMu.RLock()
|
|
observationStore := s.observationStore
|
|
scoreCalculator := s.scoreCalculator
|
|
s.initMu.RUnlock()
|
|
|
|
if observationStore == nil || scoreCalculator == nil {
|
|
http.Error(w, "service not ready", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
// Get observation
|
|
obs, err := observationStore.GetObservationByID(r.Context(), id)
|
|
if err != nil {
|
|
http.Error(w, "failed to get observation", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if obs == nil {
|
|
http.Error(w, "observation not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Calculate score components
|
|
components := scoreCalculator.CalculateComponents(obs, time.Now())
|
|
|
|
writeJSON(w, map[string]interface{}{
|
|
"id": id,
|
|
"components": components,
|
|
"config": scoreCalculator.GetConfig(),
|
|
})
|
|
}
|
|
|
|
// handleUpdateConceptWeight updates a concept weight.
|
|
// PUT /api/scoring/concepts/{concept}
|
|
func (s *Service) handleUpdateConceptWeight(w http.ResponseWriter, r *http.Request) {
|
|
concept := chi.URLParam(r, "concept")
|
|
if concept == "" {
|
|
http.Error(w, "concept is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Weight float64 `json:"weight"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Validate weight
|
|
if req.Weight < 0 || req.Weight > 1 {
|
|
http.Error(w, "weight must be between 0 and 1", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
s.initMu.RLock()
|
|
observationStore := s.observationStore
|
|
recalculator := s.recalculator
|
|
s.initMu.RUnlock()
|
|
|
|
if observationStore == nil {
|
|
http.Error(w, "service not ready", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
// Update in database
|
|
if err := observationStore.UpdateConceptWeight(r.Context(), concept, req.Weight); err != nil {
|
|
http.Error(w, "failed to update concept weight", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Refresh concept weights in recalculator
|
|
if recalculator != nil {
|
|
if refreshErr := recalculator.RefreshConceptWeights(r.Context()); refreshErr != nil {
|
|
// Log but don't fail - weight was saved
|
|
log.Warn().Err(refreshErr).Str("concept", concept).Msg("Failed to refresh concept weights in recalculator")
|
|
}
|
|
}
|
|
|
|
writeJSON(w, map[string]interface{}{
|
|
"status": "ok",
|
|
"concept": concept,
|
|
"weight": req.Weight,
|
|
})
|
|
}
|
|
|
|
// handleGetConceptWeights returns all concept weights.
|
|
// GET /api/scoring/concepts
|
|
func (s *Service) handleGetConceptWeights(w http.ResponseWriter, r *http.Request) {
|
|
s.initMu.RLock()
|
|
observationStore := s.observationStore
|
|
s.initMu.RUnlock()
|
|
|
|
if observationStore == nil {
|
|
http.Error(w, "service not ready", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
weights, err := observationStore.GetConceptWeights(r.Context())
|
|
if err != nil {
|
|
http.Error(w, "failed to get concept weights", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, weights)
|
|
}
|
|
|
|
// handleTriggerRecalculation triggers an immediate score recalculation.
|
|
// POST /api/scoring/recalculate
|
|
func (s *Service) handleTriggerRecalculation(w http.ResponseWriter, r *http.Request) {
|
|
s.initMu.RLock()
|
|
recalculator := s.recalculator
|
|
s.initMu.RUnlock()
|
|
|
|
if recalculator == nil {
|
|
http.Error(w, "recalculator not available", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
// Run recalculation in background
|
|
go func() {
|
|
if recalcErr := recalculator.RecalculateNow(r.Context()); recalcErr != nil {
|
|
// Log error but don't block response
|
|
log.Warn().Err(recalcErr).Msg("Failed to trigger score recalculation")
|
|
}
|
|
}()
|
|
|
|
writeJSON(w, map[string]string{"status": "recalculation triggered"})
|
|
}
|
|
|
|
// parseIntParam parses an integer query parameter with a default value.
|
|
func parseIntParam(r *http.Request, name string, defaultVal int) int {
|
|
if val := r.URL.Query().Get(name); val != "" {
|
|
if parsed, err := strconv.Atoi(val); err == nil && parsed > 0 {
|
|
return parsed
|
|
}
|
|
}
|
|
return defaultVal
|
|
}
|
|
|
|
// incrementRetrievalCounts increments retrieval counts for observations.
|
|
// Called after search results are returned to track popularity.
|
|
func (s *Service) incrementRetrievalCounts(ids []int64) {
|
|
if len(ids) == 0 {
|
|
return
|
|
}
|
|
|
|
s.initMu.RLock()
|
|
store := s.observationStore
|
|
s.initMu.RUnlock()
|
|
|
|
if store == nil {
|
|
return
|
|
}
|
|
|
|
// Increment in background to not block response
|
|
go func() {
|
|
// Create a new context with timeout for the background operation
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
|
|
if incrErr := store.IncrementRetrievalCount(ctx, ids); incrErr != nil {
|
|
// Log but don't fail - this is a background operation
|
|
log.Warn().Err(incrErr).Msg("Failed to increment retrieval count in background")
|
|
}
|
|
}()
|
|
}
|