Skip to content
Open
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
4 changes: 2 additions & 2 deletions backend/hoagiemeal/api/services/recommend.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,10 @@ def get_menu_items_score(
logger.debug(f"No interaction history for user_id={user.id}, returning all scores as 0.0.")
return {item_id: 0.0 for item_id in menu_item_api_ids}

similarities = MenuItemSimilarity.objects.filter(item_a__id__in=liked_items | disliked_items)
similarities = MenuItemSimilarity.objects.filter(menu_item_a__id__in=liked_items | disliked_items)
similarity_map = defaultdict(dict)
for sim in similarities:
similarity_map[sim.item_a.id][sim.item_b.id] = sim.score
similarity_map[sim.menu_item_a_id][sim.menu_item_b_id] = sim.score

scores = {}
for item_id in menu_item_api_ids:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from itertools import combinations
from django.db import transaction

from hoagiemeal.models.menu import MenuItemInteraction, MenuItemSimilarity
from hoagiemeal.models.engagement import MenuItemInteraction, MenuItemSimilarity


TOP_K = 30 # The number of similar menu items to return for each menu item.
Expand Down Expand Up @@ -71,7 +71,11 @@ def compute_similarity_scores(co_occurrence, like_counts):

for (a, b), co_count in co_occurrence.items():
# Skip if cannot compute similarity (no likes)
denom = like_counts.get(a, 0)
if co_count < 2:
continue

# Jaccard similarity
denom = like_counts.get(a, 0) + like_counts.get(b, 0) - co_count
if denom == 0:
continue

Expand Down Expand Up @@ -107,8 +111,8 @@ def persist_similarities(top_k):
# Create new MenuItemSimilarity rows
objs = [
MenuItemSimilarity(
item_a_id=a,
item_b_id=b,
menu_item_a_id=a,
menu_item_b_id=b,
score=score,
)
for a, neighbors in top_k.items()
Expand Down
122 changes: 122 additions & 0 deletions backend/hoagiemeal/management/commands/seed_interactions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Django management command to seed fake user interactions for testing recommendations.

Creates fake users with varied like/favorite patterns across existing menu items,
then runs similarity computation so the recommendation endpoint returns real scores.

Usage:
python manage.py seed_interactions # 10 fake users, 30% like rate
python manage.py seed_interactions --users 20 # 20 fake users
python manage.py seed_interactions --clear # remove seeded data first
"""

import random
from django.core.management.base import BaseCommand
from django.db import transaction

from hoagiemeal.models.user import CustomUser
from hoagiemeal.models.menu_item import MenuItem
from hoagiemeal.models.engagement import MenuItemInteraction, MenuItemMetrics
from hoagiemeal.management.commands.compute_menu_item_similarity import recompute_menu_item_similarity

SEED_PREFIX = "seeduser"


class Command(BaseCommand):
help = "Seed fake user interactions for testing the recommendation system"

def add_arguments(self, parser):
parser.add_argument("--users", type=int, default=10, help="Number of fake users to create (default: 10)")
parser.add_argument("--like-rate", type=float, default=0.3, help="Probability a user likes a given item (default: 0.3)")
parser.add_argument("--clear", action="store_true", help="Remove all seeded users and their interactions first")

def handle(self, *args, **options):
num_users = options["users"]
like_rate = options["like_rate"]

if options["clear"]:
self._clear_seeded_data()

items = list(MenuItem.objects.all())
if not items:
self.stderr.write("No menu items in the database. Run seed_menus first.")
return

self.stdout.write(f"Found {len(items)} menu items.")
self.stdout.write(f"Creating {num_users} fake users with ~{like_rate*100:.0f}% like rate...")

users = self._create_users(num_users)
self._create_interactions(users, items, like_rate)
self._update_metrics(items)

self.stdout.write("Computing similarities...")
recompute_menu_item_similarity()

self.stdout.write(self.style.SUCCESS("Done. Recommendation endpoint should now return real scores."))

def _clear_seeded_data(self):
seed_users = CustomUser.objects.filter(username__startswith=SEED_PREFIX)
count = seed_users.count()
seed_users.delete()
self.stdout.write(f"Cleared {count} seeded users and their interactions.")

def _create_users(self, num_users):
users = []
with transaction.atomic():
for i in range(num_users):
username = f"{SEED_PREFIX}_{i}"
user, _ = CustomUser.objects.get_or_create(
username=username,
defaults={"email": f"{username}@test.local", "first_name": f"Seed {i}"},
)
users.append(user)
return users

def _create_interactions(self, users, items, like_rate):
interactions = []
for user in users:
for item in items:
if random.random() < like_rate:
liked = True
favorited = random.random() < 0.2
elif random.random() < 0.1:
liked = False
favorited = False
else:
continue

interactions.append(
MenuItemInteraction(
user=user,
menu_item=item,
liked=liked,
favorited=favorited,
)
)

with transaction.atomic():
MenuItemInteraction.objects.filter(user__username__startswith=SEED_PREFIX).delete()
MenuItemInteraction.objects.bulk_create(interactions, batch_size=1000, ignore_conflicts=True)

self.stdout.write(f"Created {len(interactions)} interactions.")

def _update_metrics(self, items):
for item in items:
interactions = MenuItemInteraction.objects.filter(menu_item=item)
likes = interactions.filter(liked=True).count()
dislikes = interactions.filter(liked=False).count()
favorites = interactions.filter(favorited=True).count()

total = likes + dislikes
avg_score = (likes / total * 100) if total > 0 else None

MenuItemMetrics.objects.update_or_create(
menu_item=item,
defaults={
"like_count": likes,
"dislike_count": dislikes,
"favorite_count": favorites,
"average_like_score": avg_score,
},
)

self.stdout.write(f"Updated metrics for {len(items)} items.")
2 changes: 2 additions & 0 deletions backend/hoagiemeal/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@
update_user_menu_item_interaction,
)
from hoagiemeal.api.views.user import verify_and_get_or_create_user
from hoagiemeal.api.views.recommend import get_menu_items_score

urlpatterns = [
path("admin/", admin.site.urls),
path("api/menus/", get_or_cache_menus_and_items_for_date, name="menus-and-items-for-date"),
path("api/engagement/", get_engagement_data, name="engagement-data"),
path("api/engagement/interaction/", update_user_menu_item_interaction, name="update-user-menu-item-interaction"),
path("api/user/", verify_and_get_or_create_user, name="verify-and-get-or-create-user"),
path("api/recommend/", get_menu_items_score, name="get-menu-items-score")
]
66 changes: 66 additions & 0 deletions frontend/app/api/recommend/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* @overview Next.js Route Handler to get recommendation data.
* Requires User to be authenticated
*
*/

import { getAccessToken } from '@auth0/nextjs-auth0';
import { NextResponse } from 'next/server';

const API_URL = process.env.HOAGIE_API_URL;

export async function POST(req: Request) {
try {
const body = await req.json();
const menuItemApiIds = body.menu_item_api_ids;

if (!menuItemApiIds) {
return NextResponse.json(
{ status: 400, message: 'Missing menu_item_api_ids in request body', data: null },
{ status: 400 }
);
}

let accessToken: string | undefined;
try {
const tokenResult = await getAccessToken();
accessToken = tokenResult.accessToken;
} catch {
// Not logged in — don't silently continue
return NextResponse.json(
{ status: 401, message: 'Not authenticated', data: null },
{ status: 401 }
)
}

const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`;

const res = await fetch(`${API_URL}/api/recommend/`, {
method: 'POST',
headers,
body: JSON.stringify({ menu_item_api_ids: menuItemApiIds }),
});

const json = await res.json();

if (!json?.data) {
return NextResponse.json(
{ status: 404, message: 'No recommendation data found', data: null },
{ status: 404 }
);
}

return NextResponse.json({
data: json.data,
message: 'Successfully fetched recommendation data',
status: 200,
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unexpected error';
return NextResponse.json(
{ status: 500, message, data: null },
{ status: 500 }
);
}
}
104 changes: 102 additions & 2 deletions frontend/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,17 @@ import type { MenuSortOption } from '@/types/types';
import { MEAL_RANGES } from '@/data';
import { MEAL_COLOR_MAP } from '@/styles';
import { Meal } from '@/types/types';
import { DiningHall } from '@/locations';
import { DiningHall, locations as allLocations } from '@/locations';
import { useMenuApi } from '@/hooks/use-menu-api';
import {
useBuildResidentialDisplayData,
useBuildRetailDisplayData,
} from '@/hooks/use-build-display-data';
import { setInteractionListener } from '@/hooks/use-menu-item-interactions';
import { setInteractionListener, localInteractions } from '@/hooks/use-menu-item-interactions';
import { useMemo } from 'react';
import { useRecommendations } from '@/hooks/use-recommendations';
import { useUser } from '@auth0/nextjs-auth0/client';
import RecommendedSection from '@/components/recommended-section';

const getToday = (): Date => {
const today = new Date();
Expand Down Expand Up @@ -106,11 +110,103 @@ export default function MenuPage() {
const { data, loading } = useMenuApi(dateKey);
const preferences = usePreferencesCache();

const { user } = useUser();

// Enrich a raw menu item with interaction, metrics, and dining hall data
const enrichItem = (item: any) => {
if (!item) return item;
return {
...item,
userInteraction: data?.interactions?.[item.id] || null,
metrics: data?.metrics?.[item.id] || null,
diningHall: itemDiningHall.get(item.id) || null,
};
};

// Collect menu item IDs for the current meal and map each to its dining hall
const { currentMealItemIds, itemDiningHall } = useMemo(() => {
const ids = new Set<string>();
const hallMap = new Map<string, string>();
const addItem = (id: string, locId: string) => {
ids.add(id);
if (!hallMap.has(id)) hallMap.set(id, allLocations[locId]?.name || locId);
};
const menus = data?.residentialMenus;
if (menus) {
for (const locId in menus) {
const mealMenu = menus[locId]?.[meal];
if (!mealMenu) continue;
for (const station in mealMenu) {
for (const id of mealMenu[station] || []) addItem(id, locId);
}
}
}
const retail = data?.retailMenus;
if (retail) {
for (const locId in retail) {
const locMenu = retail[locId];
if (!locMenu) continue;
for (const key in locMenu) {
const val = locMenu[key];
if (Array.isArray(val)) { for (const id of val) addItem(id, locId); }
else if (val && typeof val === 'object') {
for (const cat in val) {
if (Array.isArray(val[cat])) { for (const id of val[cat]) addItem(id, locId); }
}
}
}
}
}
return { currentMealItemIds: ids, itemDiningHall: hallMap };
}, [data?.residentialMenus, data?.retailMenus, meal]);

const allItemIds = useMemo(() => Array.from(currentMealItemIds), [currentMealItemIds]);

const { scores, loading: recLoading } = useRecommendations(allItemIds, !!user);

const topRecommended = useMemo(() => {
if (!data?.menuItems || !scores || Object.keys(scores).length === 0) return [];

return Object.entries(scores)
.filter(([id, score]) => score > 0 && currentMealItemIds.has(id))
.sort(([, a], [, b]) => b - a)
.slice(0, 3)
.map(([id]) => enrichItem(data.menuItems[id]))
.filter(Boolean);
}, [scores, data?.menuItems, currentMealItemIds, data?.interactions, data?.metrics]);

const [interactionVersion, setInteractionVersion] = useState(0);
useEffect(() => {
setInteractionListener(() => setInteractionVersion(v => v + 1));
}, []);

const popularItems = useMemo(() => {
if (!data?.menuItems || !data?.metrics) return [];
return Array.from(currentMealItemIds)
.map((id) => enrichItem(data.menuItems[id]))
.filter((item: any) => item && data.metrics[item.id])
.sort((a: any, b: any) => {
const aNet = (data.metrics[a.id]?.likeCount ?? 0) - (data.metrics[a.id]?.dislikeCount ?? 0);
const bNet = (data.metrics[b.id]?.likeCount ?? 0) - (data.metrics[b.id]?.dislikeCount ?? 0);
return bNet - aNet;
})
.slice(0, 3);
}, [data?.menuItems, data?.metrics, currentMealItemIds]);

const favoritedItems = useMemo(() => {
if (!data?.menuItems) return [];
return Array.from(currentMealItemIds)
.map((id) => enrichItem(data.menuItems[id]))
.filter((item: any) => {
if (!item) return false;
const local = localInteractions.get(item.id);
if (local?.favorited !== undefined) return local.favorited;
const interaction = data.interactions?.[item.id];
return interaction?.favorited;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.menuItems, data?.interactions, currentMealItemIds, interactionVersion]);

const [locationType, setLocationType] = useState<'residential' | 'retail'>('residential');
const [searchTerm, setSearchTerm] = useState('');
const [sortOption, setSortOption] = useState<MenuSortOption>('None');
Expand Down Expand Up @@ -248,6 +344,10 @@ export default function MenuPage() {
/>
)}

{!loading && locationType === 'residential' && (
<RecommendedSection items={topRecommended} favoritedItems={favoritedItems} popularItems={popularItems} />
)}

<MenuCardGrid
loading={loading}
displayData={displayData}
Expand Down
Loading