| """Step 4 — measure what is actually in the catalog. |
| |
| The point of this script is to replace assumptions with numbers. The app makes |
| claims ("healthier alternative", "safe for your allergies") that are only as |
| good as the coverage underneath them, so coverage is measured rather than |
| hoped for. Run it after any pipeline change and read the output before |
| trusting a recommendation. |
| |
| Usage: |
| python pipeline/04_audit.py |
| """ |
|
|
| from __future__ import annotations |
|
|
| import os |
| import sys |
|
|
| import duckdb |
|
|
| sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
| from pipeline.config import CATALOG_PATH |
|
|
|
|
| def rule(title: str) -> None: |
| print(f"\n{'=' * 66}\n{title}\n{'=' * 66}") |
|
|
|
|
| def pct(part: int, whole: int) -> str: |
| return f"{part:>9,} ({part / whole * 100:5.1f}%)" if whole else f"{part:>9,}" |
|
|
|
|
| def main() -> None: |
| if not CATALOG_PATH.exists(): |
| raise SystemExit(f"{CATALOG_PATH} missing. Run steps 01-03 first.") |
|
|
| con = duckdb.connect(str(CATALOG_PATH), read_only=True) |
| total = con.execute("SELECT count(*) FROM catalog").fetchone()[0] |
|
|
| rule("CATALOG") |
| print(f"{'products':<42}{total:>9,}") |
| size = CATALOG_PATH.stat().st_size / 1024**2 |
| print(f"{'catalog size':<42}{size:>8.0f} MB") |
|
|
| rule("FIELD COVERAGE") |
| fields = { |
| "product name": "product_name IS NOT NULL", |
| "brand": "brands IS NOT NULL", |
| "front image": "image_url IS NOT NULL", |
| "ingredients text": "ingredients_text IS NOT NULL", |
| "ingredient tags (used for similarity)": "len(COALESCE(ingredients_tags,[]::VARCHAR[])) > 0", |
| "category (used for candidate generation)": "primary_category IS NOT NULL", |
| "allergen tags": "len(COALESCE(allergens_tags,[]::VARCHAR[])) > 0", |
| "energy": "energy_kcal_100g IS NOT NULL", |
| "sugars": "sugars_100g IS NOT NULL", |
| "saturated fat": "saturated_fat_100g IS NOT NULL", |
| "salt or sodium": "salt_derived IS NOT NULL", |
| "protein": "proteins_100g IS NOT NULL", |
| "fibre": "fiber_100g IS NOT NULL", |
| "fruit/veg estimate": "fruits_veg_derived IS NOT NULL", |
| "NOVA group": "nova_group IS NOT NULL", |
| "Eco-Score": "environmental_score_grade IS NOT NULL", |
| } |
| for label, condition in fields.items(): |
| n = con.execute(f"SELECT count(*) FROM catalog WHERE {condition}").fetchone()[0] |
| print(f"{label:<42}{pct(n, total)}") |
|
|
| rule("SCORING — the reason this pipeline exists") |
| off = con.execute( |
| "SELECT count(*) FROM catalog WHERE nutriscore_source = 'off'" |
| ).fetchone()[0] |
| ours = con.execute( |
| "SELECT count(*) FROM catalog WHERE nutriscore_source = 'nutriweb'" |
| ).fetchone()[0] |
| health = con.execute("SELECT count(health_score) FROM catalog").fetchone()[0] |
|
|
| print(f"{'Nutri-Score published by Open Food Facts':<42}{pct(off, total)}") |
| print(f"{'Nutri-Score computed by NutriWeb':<42}{pct(ours, total)}") |
| print(f"{'total graded':<42}{pct(off + ours, total)}") |
| print(f"{'with a health score':<42}{pct(health, total)}") |
| if off: |
| print(f"\n Computing grades ourselves increased scored coverage {(off + ours) / off:.2f}x.") |
|
|
| rule("VALIDATION — computed grades vs Open Food Facts' own") |
| print( |
| con.execute(""" |
| WITH v AS ( |
| SELECT nutriscore_grade_off AS off, nutriscore_grade_computed AS ours |
| FROM catalog |
| WHERE nutriscore_grade_off NOT IN ('unknown','not-applicable') |
| AND nutriscore_grade_off IS NOT NULL |
| AND nutriscore_grade_computed IS NOT NULL |
| ) |
| SELECT count(*) AS compared, |
| round(100.0*count(*) FILTER (WHERE off=ours)/count(*), 2) AS exact_pct, |
| round(100.0*count(*) FILTER (WHERE abs(ascii(off)-ascii(ours))<=1)/count(*), 2) |
| AS within_one_pct |
| FROM v |
| """).fetchdf().T.to_string(header=False) |
| ) |
|
|
| rule("CONFIDENCE — how much to trust each health score") |
| print( |
| con.execute(""" |
| SELECT COALESCE(health_confidence,'unscored') AS confidence, count(*) AS n |
| FROM catalog GROUP BY 1 ORDER BY n DESC |
| """).fetchdf().to_string(index=False) |
| ) |
| print( |
| "\n 'low' means the Nutri-Score was computed for a product with no category,\n" |
| " so the general-food thresholds were assumed. Surfaced in the UI." |
| ) |
|
|
| rule("RECOMMENDABILITY — can we actually serve a swap?") |
| print( |
| con.execute(""" |
| SELECT |
| count(*) FILTER (WHERE health_score IS NOT NULL |
| AND primary_category IS NOT NULL) AS category_path, |
| count(*) FILTER (WHERE health_score IS NOT NULL |
| AND primary_category IS NULL |
| AND len(COALESCE(ingredients_tags,[]::VARCHAR[]))>0) |
| AS ingredient_fallback_path, |
| count(*) FILTER (WHERE health_score IS NULL) AS cannot_recommend_from |
| FROM catalog |
| """).fetchdf().T.to_string(header=False) |
| ) |
|
|
| rule("TOP CATEGORIES") |
| print( |
| con.execute(""" |
| SELECT replace(split_part(primary_category,':',2),'-',' ') AS category, |
| count(*) AS products, round(avg(health_score),1) AS avg_health |
| FROM catalog WHERE primary_category IS NOT NULL AND health_score IS NOT NULL |
| GROUP BY 1 ORDER BY products DESC LIMIT 12 |
| """).fetchdf().to_string(index=False) |
| ) |
|
|
| con.close() |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|