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
Original file line number Diff line number Diff line change
Expand Up @@ -32,23 +32,22 @@ We use [Jaccard similarity](https://en.wikipedia.org/wiki/Jaccard_index) over th
score = |shared ingredients| / |union of all ingredients|
```

A recipe with no overlapping ingredients scores zero and is never shown.
A recipe has to share **at least two** ingredients with the recipe you are looking at before it can be recommended at all. A single shared ingredient (everything uses salt) is not a real signal, so we gate it out.

For signed-in users we add three more signals on top of the similarity score:
For signed-in users we add two more signals on top of the similarity score:

| Signal | Weight | Meaning |
|---|---|---|
| Ingredient similarity | 60% | Jaccard similarity |
| Owned by you | 15% | You created the recipe |
| In your Favorites list | 15% | You have favorited it |
| Novelty | 10% | Not cooked in the last 7 days |
| Ingredient similarity | 70% | Jaccard similarity |
| In your Favorites list | 18% | You have favorited it |
| Novelty | 12% | Not cooked in the last 7 days |

All of this runs as a single PostgreSQL function using CTEs, so there is no round-tripping between the API and the database to build the ranked list.
The database stays out of the policy business. It exposes a few primitives — candidate recipes that share an ingredient with the source (and their ingredient sets), your favorites, and your cook dates — and the application does the scoring, gating, and ranking. Indexes and set lookups are what databases are great at; the recommendation *algorithm* is something we would rather keep in code where it is easy to read, unit-test, and change without a migration.

## Score breakdowns for admins

If you have an Administrator account, each recommendation card shows the total score and an expandable breakdown of exactly how much each signal contributed.
This made it a lot easier to tune weights during development — we could see at a glance whether a card ranked highly because of ingredient overlap or because we happened to own the recipe.
This made it a lot easier to tune weights during development — we could see at a glance whether a card ranked highly because of ingredient overlap or because it was a favorite we had not cooked in a while.

## What we ruled out

Expand Down
15 changes: 12 additions & 3 deletions src/CookTime/Models/Contracts/RecipeRecommendationDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,6 @@ public class RecommendationScoreBreakdownDto
[JsonPropertyName("ingredientSimilarity")]
public double IngredientSimilarity { get; set; }

[JsonPropertyName("ownedByUser")]
public double OwnedByUser { get; set; }

[JsonPropertyName("favoritedByUser")]
public double FavoritedByUser { get; set; }

Expand All @@ -60,3 +57,15 @@ public class RecipeRecommendationDto
[JsonPropertyName("reasons")]
public List<string> Reasons { get; set; } = new();
}

// A candidate recipe and its set of ingredient IDs, as returned by the
// cooktime.get_candidate_ingredient_sets primitive. The recommendation
// algorithm consumes these to compute ingredient similarity.
public class CandidateIngredientSet
{
[JsonPropertyName("recipeId")]
public Guid RecipeId { get; set; }

[JsonPropertyName("ingredientIds")]
public List<Guid> IngredientIds { get; set; } = new();
}
31 changes: 13 additions & 18 deletions src/CookTime/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
var connectionString = builder.Configuration.GetConnectionString("Postgres")!;
builder.Services.AddSingleton(NpgsqlDataSource.Create(connectionString));
builder.Services.AddSingleton<CookTimeDB>();
builder.Services.AddSingleton<RecommendationService>();
builder.Services.AddSingleton<NutritionService>();
builder.Services.AddSingleton<AIRecipeService>();
builder.Services.AddMemoryCache();
Expand Down Expand Up @@ -196,24 +197,18 @@
{
return await cooktime.GetFeaturedRecipesAsync();
});
app.MapGet("/api/multipartrecipe/{id:guid}/recommendations", async (HttpContext context, CookTimeDB cooktime, Guid id, int limit = 6) =>
{
var recipe = await cooktime.GetRecipeByIdAsync(id);
if (recipe == null)
{
return Results.NotFound();
}

Guid? userId = null;
var userIdClaim = context.User.FindFirst("db_user_id")?.Value;
if (userIdClaim != null && Guid.TryParse(userIdClaim, out var parsedUserId))
{
userId = parsedUserId;
}

var recommendations = await cooktime.GetRecipeRecommendationsAsync(id, userId, Math.Clamp(limit, 1, 24));
return Results.Ok(recommendations);
});
app.MapGet("/api/multipartrecipe/{id:guid}/recommendations", async (HttpContext context, RecommendationService recommendations, Guid id, int limit = 6) =>
{
Guid? userId = null;
var userIdClaim = context.User.FindFirst("db_user_id")?.Value;
if (userIdClaim != null && Guid.TryParse(userIdClaim, out var parsedUserId))
{
userId = parsedUserId;
}

var result = await recommendations.GetRecommendationsAsync(id, userId, Math.Clamp(limit, 1, 24));
return result == null ? Results.NotFound() : Results.Ok(result);
});
app.MapGet("/api/multipartrecipe/{id:guid}", async (CookTimeDB cooktime, NutritionService nutrition, Guid id) =>
{
var recipe = await cooktime.GetRecipeByIdAsync(id);
Expand Down
86 changes: 86 additions & 0 deletions src/CookTime/Scripts/018_recommendation_primitives.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
-- Migration: Recommendation primitives
-- The recommendation algorithm now lives in the application layer. The database
-- only exposes primitives (set-membership lookups backed by indexes); scoring,
-- weighting, the minimum-overlap gate, and ranking happen in C#.
-- This replaces the monolithic cooktime.recommend_recipes function.

DROP FUNCTION IF EXISTS cooktime.recommend_recipes(uuid, uuid, integer);

-- Candidate recipes that share at least one ingredient with the source recipe,
-- each with its full set of ingredient IDs. The application computes Jaccard
-- similarity and applies the minimum-overlap gate.
CREATE OR REPLACE FUNCTION cooktime.get_candidate_ingredient_sets(p_source_recipe_id uuid)
RETURNS SETOF jsonb AS $$
BEGIN
RETURN QUERY
WITH source_ingredients AS (
SELECT DISTINCT ir.ingredient_id
FROM cooktime.recipe_components rc
JOIN cooktime.ingredient_requirements ir ON ir.recipe_component_id = rc.id
WHERE rc.recipe_id = p_source_recipe_id
AND ir.ingredient_id IS NOT NULL
),
candidate_recipes AS (
SELECT DISTINCT r.id AS recipe_id
FROM cooktime.recipes r
JOIN cooktime.recipe_components rc ON rc.recipe_id = r.id
JOIN cooktime.ingredient_requirements ir ON ir.recipe_component_id = rc.id
WHERE r.id <> p_source_recipe_id
AND ir.ingredient_id IN (SELECT ingredient_id FROM source_ingredients)
)
SELECT jsonb_build_object(
'recipeId', cr.recipe_id,
'ingredientIds', COALESCE((
SELECT jsonb_agg(DISTINCT ir.ingredient_id)
FROM cooktime.recipe_components rc
JOIN cooktime.ingredient_requirements ir ON ir.recipe_component_id = rc.id
WHERE rc.recipe_id = cr.recipe_id
AND ir.ingredient_id IS NOT NULL
), '[]'::jsonb)
)
FROM candidate_recipes cr;
END;
$$ LANGUAGE plpgsql;

-- Recipe IDs in the user's "favorites" list.
CREATE OR REPLACE FUNCTION cooktime.get_user_favorite_recipe_ids(p_user_id uuid)
RETURNS jsonb AS $$
BEGIN
RETURN COALESCE(
(
SELECT jsonb_agg(rr.recipe_id)
FROM cooktime.recipe_lists rl
JOIN cooktime.recipe_requirements rr ON rr.recipe_list_id = rl.id
WHERE rl.owner_id = p_user_id
AND lower(rl.name) = 'favorites'
),
'[]'::jsonb
);
END;
$$ LANGUAGE plpgsql;

-- The most recent cook date per recipe for the user (drives novelty scoring).
CREATE OR REPLACE FUNCTION cooktime.get_user_last_cooked(p_user_id uuid)
RETURNS SETOF jsonb AS $$
BEGIN
RETURN QUERY
SELECT jsonb_build_object(
'recipeId', e.recipe_id,
'lastCookedAt', MAX(e.cooked_at)
)
FROM cooktime.recipe_cook_events e
WHERE e.user_id = p_user_id
GROUP BY e.recipe_id;
END;
$$ LANGUAGE plpgsql;

-- Recipe summaries for the final ranked set of recommendation winners.
CREATE OR REPLACE FUNCTION cooktime.get_recipe_summaries(p_recipe_ids uuid[])
RETURNS SETOF jsonb AS $$
BEGIN
RETURN QUERY
SELECT cooktime.recipe_to_summary(r)
FROM cooktime.recipes r
WHERE r.id = ANY(p_recipe_ids);
END;
$$ LANGUAGE plpgsql;
95 changes: 83 additions & 12 deletions src/CookTime/Services/CookTimeDB.cs
Original file line number Diff line number Diff line change
Expand Up @@ -295,35 +295,106 @@ public async Task<bool> DeleteCookHistoryEventAsync(Guid userId, Guid eventId)
return (bool)result!;
}

public async Task<List<RecipeRecommendationDto>> GetRecipeRecommendationsAsync(Guid recipeId, Guid? userId = null, int limit = 6)
// Recommendation primitives. The recommendation algorithm itself lives in
// RecommendationService; these methods only fetch the data it needs.

public async Task<List<CandidateIngredientSet>> GetCandidateIngredientSetsAsync(Guid sourceRecipeId)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var cmd = new NpgsqlCommand("SELECT cooktime.recommend_recipes($1, $2, $3)", conn);
await using var cmd = new NpgsqlCommand("SELECT cooktime.get_candidate_ingredient_sets($1)", conn);

cmd.Parameters.AddWithValue(recipeId);
cmd.Parameters.Add(new NpgsqlParameter
cmd.Parameters.AddWithValue(sourceRecipeId);

var results = new List<CandidateIngredientSet>();
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
NpgsqlDbType = NpgsqlDbType.Uuid,
Value = userId.HasValue ? (object)userId.Value : DBNull.Value
});
cmd.Parameters.AddWithValue(limit);
var json = reader.GetString(0);
var candidate = JsonSerializer.Deserialize<CandidateIngredientSet>(json, JsonOptions);
if (candidate != null)
{
results.Add(candidate);
}
}

return results;
}

public async Task<HashSet<Guid>> GetFavoriteRecipeIdsAsync(Guid userId)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var cmd = new NpgsqlCommand("SELECT cooktime.get_user_favorite_recipe_ids($1)", conn);

cmd.Parameters.AddWithValue(userId);

var result = await cmd.ExecuteScalarAsync();
if (result == null || result == DBNull.Value)
{
return [];
}

var ids = JsonSerializer.Deserialize<List<Guid>>(result.ToString()!, JsonOptions);
return ids == null ? [] : [.. ids];
}

public async Task<Dictionary<Guid, DateOnly>> GetLastCookedDatesAsync(Guid userId)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var cmd = new NpgsqlCommand("SELECT cooktime.get_user_last_cooked($1)", conn);

cmd.Parameters.AddWithValue(userId);

var results = new List<RecipeRecommendationDto>();
var results = new Dictionary<Guid, DateOnly>();
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
var json = reader.GetString(0);
var entry = JsonSerializer.Deserialize<LastCookedEntry>(json, JsonOptions);
if (entry != null)
{
results[entry.RecipeId] = entry.LastCookedAt;
}
}

return results;
}

public async Task<Dictionary<Guid, RecipeSummaryDto>> GetRecipeSummariesAsync(IReadOnlyCollection<Guid> recipeIds)
{
if (recipeIds.Count == 0)
{
return [];
}

await using var conn = await dataSource.OpenConnectionAsync();
await using var cmd = new NpgsqlCommand("SELECT cooktime.get_recipe_summaries($1)", conn);

cmd.Parameters.AddWithValue(recipeIds.ToArray());

var results = new Dictionary<Guid, RecipeSummaryDto>();
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
var json = reader.GetString(0);
var dto = JsonSerializer.Deserialize<RecipeRecommendationDto>(json, JsonOptions);
if (dto != null)
var summary = JsonSerializer.Deserialize<RecipeSummaryDto>(json, JsonOptions);
if (summary != null)
{
results.Add(dto);
results[summary.Id] = summary;
}
}

return results;
}

private sealed class LastCookedEntry
{
[JsonPropertyName("recipeId")]
public Guid RecipeId { get; set; }

[JsonPropertyName("lastCookedAt")]
public DateOnly LastCookedAt { get; set; }
}


private static async Task<List<RecipeSummaryDto>> ReadRecipeSummaryListAsync(NpgsqlCommand cmd)
{
Expand Down
Loading
Loading