diff --git a/cmd/delete.go b/cmd/delete.go index b5bfe71..6d11f6a 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/streed/ml-notes/internal/logger" + "github.com/streed/ml-notes/internal/search" ) var deleteCmd = &cobra.Command{ @@ -118,8 +119,18 @@ func runDelete(_ *cobra.Command, args []string) error { fmt.Printf("✓ Deleted note %d: %s\n", id, notesToDelete[id]) successCount++ - // Vector search cleanup is handled by lil-rag service - logger.Debug("Note %d removed", id) + // Also delete from lil-rag vector index + if vectorSearch != nil { + if lilragSearch, ok := vectorSearch.(*search.LilRagSearch); ok && lilragSearch.IsAvailable() { + projectNamespace := getCurrentProjectNamespace() + if err := lilragSearch.DeleteNoteWithNamespace(id, "", projectNamespace); err != nil { + logger.Error("Failed to delete note %d from lil-rag: %v", id, err) + // Don't fail the overall deletion if lil-rag deletion fails + } else { + logger.Debug("Note %d removed from lil-rag index", id) + } + } + } } } @@ -181,6 +192,17 @@ func deleteAllNotes() error { failCount++ } else { successCount++ + + // Also delete from lil-rag vector index + if vectorSearch != nil { + if lilragSearch, ok := vectorSearch.(*search.LilRagSearch); ok && lilragSearch.IsAvailable() { + projectNamespace := getCurrentProjectNamespace() + if err := lilragSearch.DeleteNoteWithNamespace(note.ID, "", projectNamespace); err != nil { + logger.Error("Failed to delete note %d from lil-rag: %v", note.ID, err) + // Don't fail the overall deletion if lil-rag deletion fails + } + } + } } } diff --git a/internal/lilrag/client.go b/internal/lilrag/client.go index 097e0dd..6c55a99 100644 --- a/internal/lilrag/client.go +++ b/internal/lilrag/client.go @@ -48,6 +48,18 @@ type SearchResponse struct { Results []SearchResult `json:"results"` } +type DeleteRequest struct { + ID string `json:"id"` + Namespace string `json:"namespace,omitempty"` +} + +type DeleteResponse struct { + Success bool `json:"success"` + ID string `json:"id"` + Message string `json:"message"` + Status string `json:"status"` +} + func NewClient(cfg *config.Config) *Client { baseURL := cfg.LilRagURL if baseURL == "" { @@ -187,3 +199,59 @@ func (c *Client) IsAvailable() bool { logger.Debug("Lil-rag service not available at %s", c.baseURL) return false } + +func (c *Client) DeleteDocument(id string) error { + return c.DeleteDocumentWithNamespace(id, "") +} + +func (c *Client) DeleteDocumentWithNamespace(id, namespace string) error { + req := DeleteRequest{ + ID: id, + Namespace: namespace, + } + + jsonData, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal delete request: %w", err) + } + + url := c.baseURL + "/api/delete" + if namespace != "" { + logger.Debug("Deleting document %s from lil-rag at %s (namespace: %s)", id, url, namespace) + } else { + logger.Debug("Deleting document %s from lil-rag at %s", id, url) + } + + resp, err := c.httpClient.Post(url, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to send delete request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("lil-rag delete request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var deleteResp DeleteResponse + if err := json.NewDecoder(resp.Body).Decode(&deleteResp); err != nil { + return fmt.Errorf("failed to decode delete response: %w", err) + } + + // Check for success using either Success field (new format) or Status field (actual lil-rag format) + success := deleteResp.Success || deleteResp.Status == "deleted" + if !success { + message := deleteResp.Message + if message == "" && deleteResp.Status != "" { + message = deleteResp.Status + } + return fmt.Errorf("lil-rag delete failed: %s", message) + } + + message := deleteResp.Message + if message == "" && deleteResp.Status != "" { + message = deleteResp.Status + } + logger.Debug("Successfully deleted document %s: %s", deleteResp.ID, message) + return nil +} diff --git a/internal/search/lilrag_search.go b/internal/search/lilrag_search.go index 14551e9..19c8ca0 100644 --- a/internal/search/lilrag_search.go +++ b/internal/search/lilrag_search.go @@ -113,6 +113,27 @@ func (lrs *LilRagSearch) IsAvailable() bool { return lrs.client.IsAvailable() } +func (lrs *LilRagSearch) DeleteNote(noteID int) error { + return lrs.DeleteNoteWithNamespace(noteID, "", "default") +} + +func (lrs *LilRagSearch) DeleteNoteWithNamespace(noteID int, namespace, projectID string) error { + // Use project-specific note ID as document ID for lil-rag + docID := fmt.Sprintf("notes-%s-%d", projectID, noteID) + + // Create namespace with ml-notes prefix + mlNamespace := lrs.createNamespace(namespace) + + err := lrs.client.DeleteDocumentWithNamespace(docID, mlNamespace) + if err != nil { + logger.Error("Failed to delete note %d from lil-rag: %v", noteID, err) + return fmt.Errorf("failed to delete note from lil-rag: %w", err) + } + + logger.Debug("Successfully deleted note %d from lil-rag", noteID) + return nil +} + // extractNoteIDFromDocID extracts the note ID and project ID from a lil-rag document ID // Expected format: "notes-project-123" -> (123, "project") func extractNoteIDFromDocID(docID string) (int, string, error) { diff --git a/internal/search/lilrag_search_test.go b/internal/search/lilrag_search_test.go index 968854c..799ff21 100644 --- a/internal/search/lilrag_search_test.go +++ b/internal/search/lilrag_search_test.go @@ -1,6 +1,7 @@ package search import ( + "fmt" "testing" "github.com/streed/ml-notes/internal/config" @@ -42,3 +43,47 @@ func TestCreateNamespace(t *testing.T) { }) } } + +func TestDeleteNoteDocID(t *testing.T) { + // Test that note deletion uses the same document ID format as indexing + tests := []struct { + name string + noteID int + projectID string + expected string + }{ + { + name: "simple note", + noteID: 123, + projectID: "default", + expected: "notes-default-123", + }, + { + name: "complex project", + noteID: 456, + projectID: "my-awesome-project", + expected: "notes-my-awesome-project-456", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test that both index and delete use the same document ID format + indexDocID := getDocumentID(tt.noteID, tt.projectID) + deleteDocID := getDocumentID(tt.noteID, tt.projectID) + + if indexDocID != tt.expected { + t.Errorf("getDocumentID(%d, %q) = %q, want %q", tt.noteID, tt.projectID, indexDocID, tt.expected) + } + if deleteDocID != tt.expected { + t.Errorf("delete document ID should match index document ID: got %q, want %q", deleteDocID, tt.expected) + } + }) + } +} + +// Helper function to extract document ID generation logic for testing +func getDocumentID(noteID int, projectID string) string { + // This mirrors the logic in IndexNoteWithNamespace and DeleteNoteWithNamespace + return fmt.Sprintf("notes-%s-%d", projectID, noteID) +}