File size: 8,610 Bytes
89d9642 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 | """Read access to the curated DuckDB catalog.
The catalog is built offline by `pipeline/` and downloaded from the Hugging
Face Hub at app startup. Everything here is read-only and query-time.
"""
from __future__ import annotations
import os
import threading
from functools import lru_cache
from pathlib import Path
import duckdb
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
DEFAULT_CATALOG = REPO_ROOT / "data" / "nutriweb-us.duckdb"
# Set to a HF dataset repo id to fetch the catalog at startup instead of
# expecting it on local disk (this is how the Space runs).
CATALOG_REPO = os.environ.get("NUTRIWEB_CATALOG_REPO", "")
CATALOG_FILENAME = "nutriweb-us.duckdb"
# Columns the UI needs. Selecting explicitly keeps result frames small --
# `catalog` carries the full tag arrays, which are heavy to materialise.
PRODUCT_COLUMNS = """
code, product_name, generic_name, brands, quantity, serving_size,
image_url, ingredients_text, primary_category, categories_tags,
ingredients_tags, allergens_tags, traces_tags, additives_tags,
labels_tags, ingredients_analysis_tags, nutrient_levels_tags,
energy_kcal_100g, energy_kj_derived, fat_100g, saturated_fat_100g,
carbohydrates_100g, sugars_100g, fiber_100g, proteins_100g,
salt_derived, sodium_100g, fruits_veg_derived,
nova_group, environmental_score_grade,
nutriscore_grade, nutriscore_grade_off, nutriscore_grade_computed,
nutriscore_score_computed, nutriscore_source,
health_score, health_confidence, additive_penalty, n_flagged_additives,
is_beverage, unique_scans_n
"""
def catalog_path() -> Path:
"""Local path to the catalog, downloading it from the Hub if configured."""
if CATALOG_REPO:
from huggingface_hub import hf_hub_download
return Path(
hf_hub_download(
repo_id=CATALOG_REPO, filename=CATALOG_FILENAME, repo_type="dataset"
)
)
return DEFAULT_CATALOG
@lru_cache(maxsize=1)
def _database() -> duckdb.DuckDBPyConnection:
"""Open the catalog read-only, once per process."""
path = catalog_path()
if not path.exists():
raise FileNotFoundError(
f"Catalog not found at {path}. Build it with:\n"
" python pipeline/01_download.py\n"
" python pipeline/02_curate.py\n"
" python pipeline/03_score.py"
)
con = duckdb.connect(str(path), read_only=True)
_try_load_fts(con)
return con
def _try_load_fts(con: duckdb.DuckDBPyConnection) -> bool:
"""Best-effort load of the full-text-search extension.
Must never raise. The extension is present on the machine that builds the
catalog but not in a fresh container, and an unconditional `LOAD fts` there
takes the whole app down on startup.
In practice the BM25 macros are persisted inside the catalog file and work
without the extension, so this is belt-and-braces: if it cannot be loaded
we fall back to a LIKE search rather than failing.
"""
for statement in ("LOAD fts", "INSTALL fts; LOAD fts"):
try:
con.execute(statement)
return True
except duckdb.Error:
continue
return False
# Streamlit runs every user session on its own thread, and a DuckDB connection
# is not safe to share across them: two threads interleaving execute() and
# fetchdf() will consume each other's result sets, which surfaces as sporadic
# None results rather than a clean error. Each thread therefore gets its own
# cursor -- a lightweight handle onto the same open database, with its own
# result set.
_local = threading.local()
def connect() -> duckdb.DuckDBPyConnection:
"""Return this thread's cursor onto the catalog."""
con = getattr(_local, "con", None)
if con is None:
con = _database().cursor()
_try_load_fts(con)
_local.con = con
return con
def get_product(code: str) -> dict | None:
"""Fetch one product by barcode, tolerating leading-zero variants.
OFF stores EAN-13, but a scanner or a user may supply the UPC-A form
without the leading zero, so we try both.
"""
code = str(code).strip()
con = connect()
row = con.execute(
f"SELECT {PRODUCT_COLUMNS} FROM catalog WHERE code = ?", [code]
).fetchdf()
if row.empty and code.isdigit():
variants = [code.lstrip("0"), code.zfill(13), code.zfill(12)]
row = con.execute(
f"SELECT {PRODUCT_COLUMNS} FROM catalog WHERE code IN "
f"({','.join('?' * len(variants))}) LIMIT 1",
variants,
).fetchdf()
return None if row.empty else row.iloc[0].to_dict()
def search(query: str, limit: int = 30) -> list[dict]:
"""Full-text search over product name and brand.
Uses the BM25 index built in the pipeline. The app this replaces scanned
every row with `str.contains`, which does not rank and cannot use an index.
Results are biased toward products we can actually score and toward
popular items, so the first page is useful rather than merely matching.
"""
query = (query or "").strip()
if not query:
return []
con = connect()
# A bare digit string is a barcode, not a search term.
if query.isdigit() and len(query) >= 8:
product = get_product(query)
return [product] if product else []
try:
df = con.execute(
f"""
WITH scored AS (
SELECT code, fts_main_products.match_bm25(code, ?) AS relevance
FROM products
)
SELECT {PRODUCT_COLUMNS}, relevance
FROM scored JOIN catalog USING (code)
WHERE relevance IS NOT NULL
ORDER BY
relevance * (CASE WHEN health_score IS NOT NULL THEN 1.0 ELSE 0.4 END)
* (1 + ln(1 + COALESCE(unique_scans_n, 0)) / 10) DESC
LIMIT ?
""",
[query, limit],
).fetchdf()
return df.to_dict("records")
except duckdb.Error:
# The BM25 macros live in the catalog file and normally work without
# the extension, but if anything about full-text search is unavailable
# a degraded search beats a broken page.
return _search_without_fts(query, limit)
def _search_without_fts(query: str, limit: int) -> list[dict]:
"""Substring search fallback, used only when BM25 is unavailable.
Ranks a name match above a brand match and a prefix above a mid-word hit,
then favours popular, scoreable products -- roughly what BM25 gives us,
without needing the extension.
"""
pattern = f"%{query.lower()}%"
prefix = f"{query.lower()}%"
df = connect().execute(
f"""
SELECT {PRODUCT_COLUMNS}
FROM catalog
WHERE lower(product_name) LIKE ? OR lower(COALESCE(brands, '')) LIKE ?
ORDER BY
(CASE WHEN lower(product_name) LIKE ? THEN 2
WHEN lower(product_name) LIKE ? THEN 1
ELSE 0 END) DESC,
(CASE WHEN health_score IS NOT NULL THEN 1 ELSE 0 END) DESC,
COALESCE(unique_scans_n, 0) DESC
LIMIT ?
""",
[pattern, pattern, prefix, pattern, limit],
).fetchdf()
return df.to_dict("records")
@lru_cache(maxsize=1)
def macro_stats() -> dict[str, tuple[float, float]]:
"""Per-nutrient (mean, std) used to z-score the macro vector."""
from pipeline.config import MACRO_COLUMNS
row = connect().execute("SELECT * FROM macro_stats").fetchdf().iloc[0]
return {
col: (float(row[f"{col}_mean"]), float(row[f"{col}_std"]) or 1.0)
for col in MACRO_COLUMNS
}
def category_sizes(tags: list[str]) -> dict[str, int]:
"""How many scored products sit under each of these category tags."""
if not tags:
return {}
placeholders = ",".join("?" * len(tags))
rows = connect().execute(
f"SELECT tag, n FROM category_sizes WHERE tag IN ({placeholders})", list(tags)
).fetchall()
return dict(rows)
def stats() -> dict:
"""Headline catalog numbers, shown on the Insights page."""
return connect().execute("""
SELECT
count(*) AS products,
count(health_score) AS scored,
count(*) FILTER (WHERE nutriscore_source = 'off') AS graded_by_off,
count(*) FILTER (WHERE nutriscore_source = 'nutriweb') AS graded_by_nutriweb,
count(*) FILTER (WHERE image_url IS NOT NULL) AS with_image,
count(DISTINCT primary_category) AS categories
FROM catalog
""").fetchdf().iloc[0].to_dict()
|