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()