| """Build the SFT dataset for the MiniCPM4.1-8B recipe planner. |
| |
| Reads the Kaggle "better-recipes-for-a-better-life" dataset and produces |
| supervised fine-tuning pairs for BOTH planner tasks, matching the exact |
| prompt formats the app uses (src/prompts/planner_propose.txt and |
| planner_recipe.txt): |
| |
| 1. propose : ingredients -> {"options": [{name, why} x3]} |
| 2. recipe : dish + ingredients -> {"name", "cuisine", "servings", |
| "total_time_minutes", "final_dish_visual", "steps":[...]} |
| |
| Run locally (once) before fine-tuning: |
| python scripts/build_recipe_dataset.py |
| |
| Requires: |
| pip install kagglehub pandas pyarrow datasets huggingface_hub tqdm |
| ~/.kaggle/kaggle.json with your credentials |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import random |
| import re |
| import sys |
| from pathlib import Path |
|
|
| ROOT = Path(__file__).resolve().parent.parent |
| sys.path.insert(0, str(ROOT)) |
|
|
| import pandas as pd |
| from tqdm import tqdm |
|
|
| from src import config |
|
|
| random.seed(42) |
|
|
| HF_DATASET_REPO = "eldinosaur/cook-with-me-recipes-sft" |
|
|
| |
| |
| |
| |
|
|
| print("Pulling Kaggle dataset…") |
| import kagglehub |
|
|
| raw_path = Path(kagglehub.dataset_download(config.KAGGLE_DATASET)) |
| main_csv = raw_path / "recipes.csv" |
| print(f"Reading {main_csv}") |
|
|
| |
| try: |
| raw_df = pd.read_csv(main_csv, encoding="cp1252", on_bad_lines="skip") |
| except Exception: |
| raw_df = pd.read_csv(main_csv, encoding="utf-8", on_bad_lines="skip") |
|
|
| print(f"Rows: {len(raw_df)} columns: {list(raw_df.columns)}") |
|
|
|
|
| |
| |
| |
|
|
| _UNIT = ( |
| r"(cups?|tablespoons?|tbsps?|teaspoons?|tsps?|pounds?|lbs?|ounces?|ozs?|" |
| r"grams?|kgs?|mls?|liters?|pinch(?:es)?|dash(?:es)?|cloves?|cans?|" |
| r"packages?|pkgs?|sheets?|slices?|sticks?|quarts?|pints?|jars?|bunch(?:es)?|" |
| r"heads?|stalks?|sprigs?|pieces?|fillets?)" |
| ) |
| _PREP_WORDS = { |
| "peeled", "chopped", "diced", "sliced", "minced", "cored", "thawed", |
| "drained", "rinsed", "softened", "melted", "beaten", "divided", "cubed", |
| "to taste", "optional", "or more", "plus more", "for garnish", "for serving", |
| "lightly beaten", "room temperature", "at room temperature", "finely chopped", |
| "thinly sliced", "cut into", "more", "and", "or other", "such as", |
| } |
|
|
|
|
| def _clean_text(val: str) -> str: |
| if not isinstance(val, str): |
| return "" |
| |
| val = val.replace("�", " ") |
| return re.sub(r"[ \t]+", " ", val).strip() |
|
|
|
|
| def _simplify_ingredient(raw: str) -> str: |
| s = re.sub(r"\([^)]*\)", "", raw) |
| s = _clean_text(s).lower() |
| s = re.sub(r"^[\d\s./¼½¾⅓⅔⅛+-]+", "", s) |
| s = re.sub(rf"^{_UNIT}\b\.?\s*", "", s) |
| s = re.sub(r"^(of|the|a|an)\s+", "", s) |
| s = s.split(",")[0] |
| s = re.sub(r"[^a-z\s-]", "", s) |
| s = re.sub(r"\s+", " ", s).strip() |
| return s |
|
|
|
|
| def _ingredient_list(raw: str) -> list[str]: |
| if not isinstance(raw, str): |
| return [] |
| out, seen = [], set() |
| for part in raw.split(","): |
| name = _simplify_ingredient(part) |
| if not name or len(name) < 3 or len(name.split()) > 4: |
| continue |
| if name in _PREP_WORDS or name in seen: |
| continue |
| seen.add(name) |
| out.append(name) |
| return out |
|
|
|
|
| def _steps_from_directions(raw: str) -> list[str]: |
| if not isinstance(raw, str): |
| return [] |
| raw = _clean_text(raw.replace("\r", "\n")) |
| |
| parts = [p.strip() for p in raw.split("\n") if p.strip()] |
| if len(parts) < 2: |
| parts = [p.strip() for p in re.split(r"(?<=[.!?])\s+(?=[A-Z])", raw) if p.strip()] |
| |
| steps: list[str] = [] |
| for p in parts: |
| if steps and len(p) < 25: |
| steps[-1] = steps[-1] + " " + p |
| else: |
| steps.append(p) |
| return [s for s in steps if len(s) > 15] |
|
|
|
|
| def _minutes(row) -> int: |
| for col in ("total_time", "cook_time", "prep_time"): |
| v = row.get(col) |
| if isinstance(v, str): |
| h = re.search(r"(\d+)\s*hr", v) |
| m = re.search(r"(\d+)\s*min", v) |
| total = (int(h.group(1)) * 60 if h else 0) + (int(m.group(1)) if m else 0) |
| if total: |
| return total |
| return 0 |
|
|
|
|
| def _cuisine(row) -> str: |
| cp = row.get("cuisine_path") |
| if isinstance(cp, str): |
| segs = [s for s in cp.split("/") if s] |
| if segs: |
| return segs[0].replace("-", " ").strip().title() |
| return "International" |
|
|
|
|
| def _distribute(total: int, n: int) -> list[int]: |
| if n <= 0: |
| return [] |
| if total <= 0: |
| total = n * 6 |
| base = max(2, total // n) |
| durs = [base] * n |
| durs[-1] = max(2, total - base * (n - 1)) |
| return durs |
|
|
|
|
| |
| |
| |
|
|
| recipes: list[dict] = [] |
| for _, r in tqdm(raw_df.iterrows(), total=len(raw_df), desc="Normalizing"): |
| name = _clean_text(r.get("recipe_name", "")) |
| ings = _ingredient_list(r.get("ingredients", "")) |
| steps = _steps_from_directions(r.get("directions", "")) |
| if not name or len(ings) < 3 or len(steps) < 2: |
| continue |
| steps = steps[:7] |
| if len(steps) < 4 and len(steps) >= 2: |
| pass |
| minutes = _minutes(r) or len(steps) * 6 |
| try: |
| servings = int(float(str(r.get("servings", "2")).split()[0])) |
| except Exception: |
| servings = 2 |
| servings = min(max(servings, 1), 12) |
| recipes.append({ |
| "name": name, |
| "ingredients": ings[:14], |
| "steps": steps, |
| "cuisine": _cuisine(r), |
| "minutes": int(minutes), |
| "servings": servings, |
| }) |
|
|
| print(f"\nClean recipes: {len(recipes)}") |
|
|
| config.DATA_DIR.mkdir(parents=True, exist_ok=True) |
| pd.DataFrame(recipes).to_parquet(config.RECIPES_PARQUET, index=False) |
| print(f"Saved -> {config.RECIPES_PARQUET}") |
|
|
|
|
| |
| |
| |
|
|
| PROPOSE_TMPL = (config.PROMPTS_DIR / "planner_propose.txt").read_text(encoding="utf-8") |
| RECIPE_TMPL = (config.PROMPTS_DIR / "planner_recipe.txt").read_text(encoding="utf-8") |
|
|
| _WHY = [ |
| "Uses your {a} and {b} for a quick, satisfying result.", |
| "A fresh way to combine {a} with {b}.", |
| "Turns {a} and {b} into a comforting classic.", |
| "Light and flavorful, built around {a} and {b}.", |
| "Makes the most of {a}, {b} and a few pantry staples.", |
| ] |
|
|
|
|
| def _recipe_json(rec: dict) -> str: |
| durs = _distribute(rec["minutes"], len(rec["steps"])) |
| steps = [ |
| {"n": i + 1, "instruction": s, "duration": f"{d} min", "tip": None} |
| for i, (s, d) in enumerate(zip(rec["steps"], durs)) |
| ] |
| obj = { |
| "name": rec["name"], |
| "cuisine": rec["cuisine"], |
| "servings": rec["servings"], |
| "total_time_minutes": rec["minutes"], |
| "final_dish_visual": f"A beautifully plated {rec['name'].lower()}, ready to serve.", |
| "steps": steps, |
| } |
| return json.dumps(obj, ensure_ascii=False) |
|
|
|
|
| def _propose_json(rec: dict, others: list[dict]) -> str: |
| a = rec["ingredients"][0] if rec["ingredients"] else "your ingredients" |
| b = rec["ingredients"][1] if len(rec["ingredients"]) > 1 else "pantry staples" |
| options = [{"name": rec["name"], "why": random.choice(_WHY).format(a=a, b=b)}] |
| for o in others: |
| oa = o["ingredients"][0] if o["ingredients"] else a |
| ob = o["ingredients"][1] if len(o["ingredients"]) > 1 else b |
| options.append({"name": o["name"], "why": random.choice(_WHY).format(a=oa, b=ob)}) |
| return json.dumps({"options": options}, ensure_ascii=False) |
|
|
|
|
| sft_path = config.DATA_DIR / "recipes_sft.jsonl" |
| n_recipe = n_propose = 0 |
| with open(sft_path, "w", encoding="utf-8") as f: |
| for idx, rec in enumerate(tqdm(recipes, desc="Building SFT")): |
| ing_str = ", ".join(rec["ingredients"]) |
|
|
| |
| user_recipe = RECIPE_TMPL.replace("{dish_name}", rec["name"]).replace("{ingredients}", ing_str) |
| f.write(json.dumps({"messages": [ |
| {"role": "user", "content": user_recipe}, |
| {"role": "assistant", "content": _recipe_json(rec)}, |
| ]}, ensure_ascii=False) + "\n") |
| n_recipe += 1 |
|
|
| |
| others = [recipes[(idx + 7) % len(recipes)], recipes[(idx + 53) % len(recipes)]] |
| user_propose = PROPOSE_TMPL.replace("{ingredients}", ing_str) |
| f.write(json.dumps({"messages": [ |
| {"role": "user", "content": user_propose}, |
| {"role": "assistant", "content": _propose_json(rec, others)}, |
| ]}, ensure_ascii=False) + "\n") |
| n_propose += 1 |
|
|
| print(f"\nSFT pairs: {n_recipe} recipe + {n_propose} propose = {n_recipe + n_propose} -> {sft_path}") |
|
|
|
|
| |
| |
| |
|
|
| if HF_DATASET_REPO: |
| from datasets import load_dataset |
|
|
| ds = load_dataset("json", data_files=str(sft_path), split="train") |
| ds.push_to_hub(HF_DATASET_REPO) |
| print(f"Pushed {len(ds)} rows to {HF_DATASET_REPO}") |
|
|
| print("\nDone.") |
|
|