Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions cmd/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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)
}
}
}
}
}

Expand Down Expand Up @@ -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
}
}
}
}
}

Expand Down
68 changes: 68 additions & 0 deletions internal/lilrag/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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
}
21 changes: 21 additions & 0 deletions internal/search/lilrag_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
45 changes: 45 additions & 0 deletions internal/search/lilrag_search_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package search

import (
"fmt"
"testing"

"github.com/streed/ml-notes/internal/config"
Expand Down Expand Up @@ -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)
}
Comment on lines +86 to +89
Copy link

Copilot AI Sep 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helper function duplicates the document ID generation logic from the production code. Consider extracting the actual document ID generation into a shared utility function to avoid code duplication and ensure consistency.

Copilot uses AI. Check for mistakes.
Loading