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
109 changes: 108 additions & 1 deletion internal/handlers/similarity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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=<url>&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")
Expand Down
122 changes: 121 additions & 1 deletion internal/handlers/similarity_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
}
2 changes: 2 additions & 0 deletions internal/repository/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
30 changes: 30 additions & 0 deletions internal/repository/vector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion internal/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions internal/testutil/mocks_vector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

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