From e6f3e961420a049ea36eb5f4e3ec12e98645cb18 Mon Sep 17 00:00:00 2001 From: Julian Dice <19397727+windoze95@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:35:28 -0500 Subject: [PATCH] Add similar-recipes-by-URL for the preview screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preview screen (where shared/deep links land, and every search result before import) had no "similar recipes" section — that only existed on the saved-recipe detail screen, which needs a saved id. New GET /v1/recipes/similar-by-url?u=: resolves the page to its canonical extraction, reuses that entry's stored embedding (generating + persisting one only when the backfill hasn't yet), and returns similar entries from the extraction pool as lightweight {title, source_url, source_domain} cards. A cache miss is not an error — it returns an empty list so the section quietly hides. VectorRepo gains FindSimilarCanonicals (+ UpdateCanonicalEmbedding surfaced on the interface). Co-Authored-By: Claude Fable 5 --- internal/handlers/similarity.go | 109 ++++++++++++++++- internal/handlers/similarity_handler_test.go | 122 ++++++++++++++++++- internal/repository/interfaces.go | 2 + internal/repository/vector.go | 30 +++++ internal/router/router.go | 5 +- internal/testutil/mocks_vector.go | 16 +++ 6 files changed, 281 insertions(+), 3 deletions(-) diff --git a/internal/handlers/similarity.go b/internal/handlers/similarity.go index 35b1e5c..ae6a31c 100644 --- a/internal/handlers/similarity.go +++ b/internal/handlers/similarity.go @@ -2,7 +2,9 @@ package handlers import ( "net/http" + "net/url" "strconv" + "strings" "github.com/gin-gonic/gin" "github.com/windoze95/saltybytes-api/internal/ai" @@ -20,19 +22,124 @@ const ( // SimilarityHandler handles vector similarity search requests. type SimilarityHandler struct { VectorRepo repository.VectorRepo + CanonicalRepo repository.CanonicalRecipeRepo EmbedProvider ai.EmbeddingProvider RecipeService *service.RecipeService } // NewSimilarityHandler creates a new SimilarityHandler. -func NewSimilarityHandler(vectorRepo repository.VectorRepo, embedProvider ai.EmbeddingProvider, recipeService *service.RecipeService) *SimilarityHandler { +func NewSimilarityHandler(vectorRepo repository.VectorRepo, canonicalRepo repository.CanonicalRecipeRepo, embedProvider ai.EmbeddingProvider, recipeService *service.RecipeService) *SimilarityHandler { return &SimilarityHandler{ VectorRepo: vectorRepo, + CanonicalRepo: canonicalRepo, EmbedProvider: embedProvider, RecipeService: recipeService, } } +// similarWebRecipe is a lightweight card for a canonical (extracted) recipe: +// enough to render and to re-open its preview by source URL. Canonicals carry +// no image, so none is returned. +type similarWebRecipe struct { + Title string `json:"title"` + SourceURL string `json:"source_url"` + SourceDomain string `json:"source_domain"` +} + +// FindSimilarByURL handles GET /v1/recipes/similar-by-url?u=&limit=N. +// It powers the preview screen's "similar recipes" strip: the previewed page +// isn't a saved recipe, so similarity is computed against the canonical +// extraction pool using the page's cached embedding (generated on demand and +// persisted when absent). A cache miss is not an error — it just yields an +// empty list so the section quietly hides. +func (h *SimilarityHandler) FindSimilarByURL(c *gin.Context) { + rawURL := c.Query("u") + if rawURL == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing url"}) + return + } + if h.CanonicalRepo == nil { + c.JSON(http.StatusOK, gin.H{"similar_recipes": []similarWebRecipe{}}) + return + } + + limit := defaultSimilarLimit + if l := c.Query("limit"); l != "" { + if v, convErr := strconv.Atoi(l); convErr == nil && v > 0 { + limit = v + } + } + if limit > maxSimilarLimit { + limit = maxSimilarLimit + } + + normalizedURL, err := service.NormalizeURL(rawURL) + if err != nil { + c.JSON(http.StatusOK, gin.H{"similar_recipes": []similarWebRecipe{}}) + return + } + canonical, err := h.CanonicalRepo.GetByNormalizedURL(normalizedURL) + if err != nil || canonical == nil || canonical.IsMultiPage || canonical.RecipeData.Title == "" { + c.JSON(http.StatusOK, gin.H{"similar_recipes": []similarWebRecipe{}}) + return + } + + // Reuse the page's stored embedding; generate + persist one only when the + // extraction hasn't been embedded yet (the backfill usually has). + var embeddingLiteral string + if canonical.Embedding != nil && *canonical.Embedding != "" { + embeddingLiteral = *canonical.Embedding + } else if h.EmbedProvider != nil { + text := canonical.RecipeData.Title + for _, ing := range canonical.RecipeData.Ingredients { + text += " " + ing.Name + } + embedding, genErr := h.EmbedProvider.GenerateEmbedding(c.Request.Context(), text) + if genErr != nil { + logger.Get().Warn("similar-by-url: embedding generation failed", zap.Uint("canonical_id", canonical.ID), zap.Error(genErr)) + c.JSON(http.StatusOK, gin.H{"similar_recipes": []similarWebRecipe{}}) + return + } + if storeErr := h.VectorRepo.UpdateCanonicalEmbedding(canonical.ID, embedding); storeErr != nil { + logger.Get().Warn("similar-by-url: failed to persist embedding", zap.Uint("canonical_id", canonical.ID), zap.Error(storeErr)) + } + embeddingLiteral = repository.PgvectorLiteral(embedding) + } else { + c.JSON(http.StatusOK, gin.H{"similar_recipes": []similarWebRecipe{}}) + return + } + + similar, err := h.VectorRepo.FindSimilarCanonicals(embeddingLiteral, canonical.ID, limit) + if err != nil { + logger.Get().Error("similar-by-url: query failed", zap.Uint("canonical_id", canonical.ID), zap.Error(err)) + c.JSON(http.StatusOK, gin.H{"similar_recipes": []similarWebRecipe{}}) + return + } + + items := make([]similarWebRecipe, 0, len(similar)) + for _, entry := range similar { + source := entry.RecipeData.SourceURL + if source == "" { + source = entry.OriginalURL + } + items = append(items, similarWebRecipe{ + Title: entry.RecipeData.Title, + SourceURL: source, + SourceDomain: similarDomainOf(source), + }) + } + c.JSON(http.StatusOK, gin.H{"similar_recipes": items}) +} + +// similarDomainOf extracts a bare hostname for display. +func similarDomainOf(raw string) string { + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" { + return "" + } + return strings.TrimPrefix(parsed.Host, "www.") +} + // FindSimilar handles GET /v1/recipes/similar/:recipe_id?limit=N func (h *SimilarityHandler) FindSimilar(c *gin.Context) { recipeIDStr := c.Param("recipe_id") diff --git a/internal/handlers/similarity_handler_test.go b/internal/handlers/similarity_handler_test.go index dee4704..5605888 100644 --- a/internal/handlers/similarity_handler_test.go +++ b/internal/handlers/similarity_handler_test.go @@ -3,8 +3,10 @@ package handlers import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -27,7 +29,15 @@ func newSimilarityFixture(vectorRepo *testutil.MockVectorRepo, embedProvider *te repo.Recipes[recipe.ID] = recipe svc := service.NewRecipeService(&config.Config{}, repo, &testutil.MockTextProvider{}, &testutil.MockImageProvider{}) - return NewSimilarityHandler(vectorRepo, embedProvider, svc) + return NewSimilarityHandler(vectorRepo, nil, embedProvider, svc) +} + +// newSimilarByURLFixture builds a SimilarityHandler wired with a canonical +// repo for the preview-screen similarity endpoint. +func newSimilarByURLFixture(vectorRepo *testutil.MockVectorRepo, canonicalRepo *testutil.MockCanonicalRecipeRepo, embedProvider *testutil.MockEmbeddingProvider) *SimilarityHandler { + repo := testutil.NewMockRecipeRepo() + svc := service.NewRecipeService(&config.Config{}, repo, &testutil.MockTextProvider{}, &testutil.MockImageProvider{}) + return NewSimilarityHandler(vectorRepo, canonicalRepo, embedProvider, svc) } func similarRecipe(id uint, title string) models.Recipe { @@ -223,3 +233,113 @@ func TestFindSimilar_InvalidID(t *testing.T) { t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest) } } + +func TestFindSimilarByURL_UsesStoredCanonicalEmbedding(t *testing.T) { + stored := "[0.4,0.5,0.6]" + src := &models.CanonicalRecipe{ + OriginalURL: "https://pinchofyum.com/bang-bang-salmon", + Embedding: &stored, + RecipeData: models.RecipeDef{ + Title: "Bang Bang Salmon", + SourceURL: "https://pinchofyum.com/bang-bang-salmon", + }, + } + src.ID = 42 + + match := models.CanonicalRecipe{ + OriginalURL: "https://pinchofyum.com/miso-ramen", + RecipeData: models.RecipeDef{ + Title: "Miso Peanut Ramen Bowls", + SourceURL: "https://www.pinchofyum.com/miso-ramen", + }, + } + match.ID = 43 + + embedCalled := false + vectorRepo := &testutil.MockVectorRepo{ + FindSimilarCanonicalsFunc: func(lit string, exclude uint, limit int) ([]models.CanonicalRecipe, error) { + if lit != stored { + t.Errorf("used embedding %q, want stored %q", lit, stored) + } + if exclude != 42 { + t.Errorf("excluded %d, want 42", exclude) + } + return []models.CanonicalRecipe{match}, nil + }, + } + canonicalRepo := &testutil.MockCanonicalRecipeRepo{ + GetByNormalizedURLFunc: func(string) (*models.CanonicalRecipe, error) { return src, nil }, + } + embedProvider := &testutil.MockEmbeddingProvider{ + GenerateEmbeddingFunc: func(context.Context, string) ([]float32, error) { embedCalled = true; return nil, nil }, + } + + handler := newSimilarByURLFixture(vectorRepo, canonicalRepo, embedProvider) + r := gin.New() + r.GET("/recipes/similar-by-url", handler.FindSimilarByURL) + + req := httptest.NewRequest("GET", "/recipes/similar-by-url?u=https://pinchofyum.com/bang-bang-salmon", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + if embedCalled { + t.Error("should not generate an embedding when one is stored") + } + var body struct { + SimilarRecipes []struct { + Title string `json:"title"` + SourceURL string `json:"source_url"` + SourceDomain string `json:"source_domain"` + } `json:"similar_recipes"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if len(body.SimilarRecipes) != 1 { + t.Fatalf("got %d items, want 1", len(body.SimilarRecipes)) + } + got := body.SimilarRecipes[0] + if got.Title != "Miso Peanut Ramen Bowls" || got.SourceDomain != "pinchofyum.com" { + t.Errorf("item = %+v", got) + } +} + +func TestFindSimilarByURL_CacheMissReturnsEmpty(t *testing.T) { + canonicalRepo := &testutil.MockCanonicalRecipeRepo{ + GetByNormalizedURLFunc: func(string) (*models.CanonicalRecipe, error) { + return nil, fmt.Errorf("not found") + }, + } + handler := newSimilarByURLFixture(&testutil.MockVectorRepo{}, canonicalRepo, &testutil.MockEmbeddingProvider{}) + r := gin.New() + r.GET("/recipes/similar-by-url", handler.FindSimilarByURL) + + req := httptest.NewRequest("GET", "/recipes/similar-by-url?u=https://example.com/never-seen", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + // A miss is not an error: the section just hides. + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + if !strings.Contains(w.Body.String(), `"similar_recipes":[]`) { + t.Errorf("body = %s, want empty list", w.Body.String()) + } +} + +func TestFindSimilarByURL_MissingURL(t *testing.T) { + handler := newSimilarByURLFixture(&testutil.MockVectorRepo{}, &testutil.MockCanonicalRecipeRepo{}, &testutil.MockEmbeddingProvider{}) + r := gin.New() + r.GET("/recipes/similar-by-url", handler.FindSimilarByURL) + + req := httptest.NewRequest("GET", "/recipes/similar-by-url", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} diff --git a/internal/repository/interfaces.go b/internal/repository/interfaces.go index c45f8a2..52f72e2 100644 --- a/internal/repository/interfaces.go +++ b/internal/repository/interfaces.go @@ -41,8 +41,10 @@ type RecipeRepo interface { // VectorRepo is the interface for pgvector similarity search operations. type VectorRepo interface { FindSimilar(embeddingLiteral string, excludeRecipeID uint, limit int) ([]models.Recipe, error) + FindSimilarCanonicals(embeddingLiteral string, excludeCanonicalID uint, limit int) ([]models.CanonicalRecipe, error) GetRecipeEmbedding(recipeID uint) (*string, error) UpdateEmbedding(recipeID uint, embedding []float32) error + UpdateCanonicalEmbedding(canonicalID uint, embedding []float32) error SearchUserRecipesByEmbedding(userID uint, embeddingLiteral string, limit int) ([]models.Recipe, error) SearchUserRecipesByTitle(userID uint, query string, onlyMissingEmbedding bool, limit int) ([]models.Recipe, error) } diff --git a/internal/repository/vector.go b/internal/repository/vector.go index abf2a3a..68bac7b 100644 --- a/internal/repository/vector.go +++ b/internal/repository/vector.go @@ -59,6 +59,36 @@ func (r *VectorRepository) FindSimilar(embeddingLiteral string, excludeRecipeID return recipes, nil } +// FindSimilarCanonicals finds canonical (extracted) recipes similar to the +// given embedding, using cosine distance. Multi-page markers and untitled +// entries are excluded, along with the source canonical itself. This powers +// the "similar recipes" strip on the preview screen, where the viewed recipe +// is not (yet) a saved recipe but the extraction pool is a rich pool of real +// web recipes. +func (r *VectorRepository) FindSimilarCanonicals(embeddingLiteral string, excludeCanonicalID uint, limit int) ([]models.CanonicalRecipe, error) { + if limit <= 0 { + limit = 10 + } + + distanceExpr := fmt.Sprintf("embedding <=> '%s'", embeddingLiteral) + + var entries []models.CanonicalRecipe + err := r.DB. + Where("embedding IS NOT NULL"). + Where("id != ?", excludeCanonicalID). + Where("is_multi_page = ?", false). + Where("recipe_data->>'title' <> ''"). + Where(distanceExpr+" < ?", SimilarRecipeDistanceThreshold). + Order(distanceExpr). + Limit(limit). + Find(&entries).Error + if err != nil { + return nil, fmt.Errorf("failed to find similar canonicals: %w", err) + } + + return entries, nil +} + // GetRecipeEmbedding returns the stored embedding literal for a recipe, or nil // when the recipe has no embedding. func (r *VectorRepository) GetRecipeEmbedding(recipeID uint) (*string, error) { diff --git a/internal/router/router.go b/internal/router/router.go index 1bb7ac4..af5a144 100755 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -438,8 +438,11 @@ func SetupRouter(cfg *config.Config, database *gorm.DB) *gin.Engine { apiProtected.DELETE("/recipes/finder/sessions/:session_id", middleware.AttachUserToContext(userService), finderSessionHandler.DeleteSession) // Vector similarity routes - similarityHandler := handlers.NewSimilarityHandler(vectorRepo, embedProvider, recipeService) + similarityHandler := handlers.NewSimilarityHandler(vectorRepo, canonicalRepo, embedProvider, recipeService) apiProtected.GET("/recipes/similar/:recipe_id", middleware.AttachUserToContext(userService), similarityHandler.FindSimilar) + // Similar recipes for a not-yet-saved page (the preview screen), computed + // against the extraction pool. No user context needed — it's read-only. + apiProtected.GET("/recipes/similar-by-url", similarityHandler.FindSimilarByURL) // Subscription routes subHandler := handlers.NewSubscriptionHandler(subService) diff --git a/internal/testutil/mocks_vector.go b/internal/testutil/mocks_vector.go index 2678b9d..34d614d 100644 --- a/internal/testutil/mocks_vector.go +++ b/internal/testutil/mocks_vector.go @@ -14,8 +14,10 @@ import ( // (or an error where a result is required). type MockVectorRepo struct { FindSimilarFunc func(embeddingLiteral string, excludeRecipeID uint, limit int) ([]models.Recipe, error) + FindSimilarCanonicalsFunc func(embeddingLiteral string, excludeCanonicalID uint, limit int) ([]models.CanonicalRecipe, error) GetRecipeEmbeddingFunc func(recipeID uint) (*string, error) UpdateEmbeddingFunc func(recipeID uint, embedding []float32) error + UpdateCanonicalEmbeddingFunc func(canonicalID uint, embedding []float32) error SearchUserRecipesByEmbeddingFunc func(userID uint, embeddingLiteral string, limit int) ([]models.Recipe, error) SearchUserRecipesByTitleFunc func(userID uint, query string, onlyMissingEmbedding bool, limit int) ([]models.Recipe, error) @@ -54,6 +56,20 @@ func (m *MockVectorRepo) FindSimilar(embeddingLiteral string, excludeRecipeID ui return []models.Recipe{}, nil } +func (m *MockVectorRepo) FindSimilarCanonicals(embeddingLiteral string, excludeCanonicalID uint, limit int) ([]models.CanonicalRecipe, error) { + if m.FindSimilarCanonicalsFunc != nil { + return m.FindSimilarCanonicalsFunc(embeddingLiteral, excludeCanonicalID, limit) + } + return []models.CanonicalRecipe{}, nil +} + +func (m *MockVectorRepo) UpdateCanonicalEmbedding(canonicalID uint, embedding []float32) error { + if m.UpdateCanonicalEmbeddingFunc != nil { + return m.UpdateCanonicalEmbeddingFunc(canonicalID, embedding) + } + return nil +} + func (m *MockVectorRepo) GetRecipeEmbedding(recipeID uint) (*string, error) { m.GetRecipeEmbeddingCalls = append(m.GetRecipeEmbeddingCalls, recipeID) if m.GetRecipeEmbeddingFunc != nil {