"""Healthier swaps for the selected product."""
from __future__ import annotations
import streamlit as st
from components import badges, cards
from nutriweb.data import catalog
from nutriweb.reco import engine
from views import state
code = state.require_selection()
if not code:
st.stop()
product = catalog.get_product(code)
if product is None:
st.error(f"No product found for barcode {code}.")
st.stop()
profile = state.profile()
st.markdown(
f"""
Healthier than {badges.title_of(product)}
Same kind of product, better health score, filtered against your profile.
""",
unsafe_allow_html=True,
)
col_left, col_right = st.columns([1, 3], gap="large")
with col_left:
st.markdown(
f'{badges.thumb(product)}'
f"
{badges.title_of(product)}
"
f'
{badges.brand_of(product) or " "}
'
f"{badges.badge_row(product)}"
f'{badges.health_meter(product.get("health_score"))}
',
unsafe_allow_html=True,
)
if st.button("← Back to product", width='stretch'):
state.open_product(code)
with col_right:
top_n = st.slider("How many alternatives", 3, 12, 6, key="rec_n")
with st.spinner("Finding alternatives..."):
recommendations, basis = engine.recommend(product, profile, top_n=top_n)
if state.profile_is_empty():
st.info(
"You have no profile set, so these results are not filtered for allergens "
"or diet. Add them under **My profile** for personalised swaps."
)
if not recommendations:
message = basis.get("message", "No healthier alternatives found.")
# "Already the best option" is good news, not a failure.
if basis.get("reason") == "already_best":
st.success(f"✓ {message}")
else:
st.warning(message)
if basis.get("reason") == "filtered_out":
st.caption("Relaxing a dietary preference would surface these.")
st.stop()
# Be explicit about how the candidate set was chosen, rather than
# presenting results as if they came from nowhere.
if basis["mode"] == "category":
label = str(basis["category"]).split(":", 1)[-1].replace("-", " ")
st.caption(
f"Compared against **{basis['pool']}** products in **{label}** that pass your filters."
)
else:
st.caption(
f"This product has no category in Open Food Facts, so alternatives were "
f"matched by shared ingredients across **{basis['pool']}** candidates."
)
for start in range(0, len(recommendations), 3):
row = recommendations[start : start + 3]
for column, rec in zip(st.columns(3), row):
with column:
cards.recommendation_card(
rec,
key=f"rec_{rec.product['code']}",
on_open=state.open_product,
)
with st.expander("How these were ranked"):
st.markdown(
f"""
Candidates must be **healthier** than the original and must pass every hard
filter from your profile — allergens and diet are exclusions, never trade-offs.
Survivors are then ranked on a weighted blend:
| Signal | Weight | What it measures |
|---|---|---|
| Health gain | {engine.W_HEALTH_GAIN:.0%} | Improvement in the 0–100 NutriWeb health score |
| Macro similarity | {engine.W_MACRO:.0%} | Distance across energy, fat, carbs, sugar, fibre, protein and salt |
| Ingredient overlap | {engine.W_INGREDIENT:.0%} | Jaccard overlap of Open Food Facts' canonical ingredient tags |
| Popularity | {engine.W_POPULARITY:.0%} | Scan count, used only to break ties |
The health score itself is 70% Nutri-Score 2023, 30% NOVA processing group,
minus a penalty for additives flagged by EFSA or ANSES.
"""
)