kiwi/scripts/pipeline/derive_substitutions.py
pyr0ball c18bfec8f5
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run
fix(lint): add ruff config, fix all lint errors for GitHub CI
- pyproject.toml: add [tool.ruff] config suppressing E402/W293 globally,
  F841/E741/E702 in tests, E741 in scripts
- inventory.py: split semicolon import (E702)
- recipes.py: fix logger -> log (F821 undefined name)
- shopping.py: rename l -> lnk in list comprehension (E741)
- format_conversion.py: noqa F841 on CUDA flag (used as future hook)
- backfill_keywords.py: rename done -> _done (F841)
- ingest_purplecarrot.py: drop == True comparison (E712)
- Auto-fix: I001 import sorting, F401 unused imports across all files
2026-07-06 02:49:02 -07:00

135 lines
5.3 KiB
Python

"""
Derive substitution pairs by diffing lishuyang/recipepairs.
GPL-3.0 source -- derived annotations only, raw pairs not shipped.
Usage:
PYTHONPATH=/path/to/kiwi conda run -n cf python scripts/pipeline/derive_substitutions.py \
--db /path/to/kiwi.db \
--recipepairs data/pipeline/recipepairs.parquet \
--recipepairs-recipes data/pipeline/recipepairs_recipes.parquet
"""
from __future__ import annotations
import argparse
import json
import re
import sqlite3
from collections import defaultdict
from pathlib import Path
import pandas as pd
def diff_ingredients(base: list[str], target: list[str]) -> tuple[list[str], list[str]]:
base_set = set(base)
target_set = set(target)
removed = list(base_set - target_set)
added = list(target_set - base_set)
return removed, added
def _parse_categories(val: object) -> list[str]:
"""Parse categories field which may be a list, str-repr list, or bare string."""
if isinstance(val, list):
return [str(v) for v in val]
if isinstance(val, str):
val = val.strip()
if val.startswith("["):
# parse list repr: ['a', 'b'] — use json after converting single quotes
try:
fixed = re.sub(r"'", '"', val)
return json.loads(fixed)
except Exception:
pass
return [val] if val else []
return []
def build(db_path: Path, recipepairs_path: Path, recipes_path: Path) -> None:
conn = sqlite3.connect(db_path)
try:
# Load ingredient lists from the bundled recipepairs recipe corpus.
# This is GPL-3.0 data — we only use it for diffing; raw data is not persisted.
print("Loading recipe ingredient index from recipepairs corpus...")
recipes_df = pd.read_parquet(recipes_path, columns=["id", "ingredients"])
recipe_ingredients: dict[str, list[str]] = {}
for _, r in recipes_df.iterrows():
ings = r["ingredients"]
if ings is not None and hasattr(ings, "__iter__") and not isinstance(ings, str):
recipe_ingredients[str(int(r["id"]))] = [str(i) for i in ings]
print(f" {len(recipe_ingredients)} recipes loaded")
pairs_df = pd.read_parquet(recipepairs_path)
pair_counts: dict[tuple, dict] = defaultdict(lambda: {"count": 0})
print("Diffing recipe pairs...")
for _, row in pairs_df.iterrows():
base_id = str(int(row["base"]))
target_id = str(int(row["target"]))
base_ings = recipe_ingredients.get(base_id, [])
target_ings = recipe_ingredients.get(target_id, [])
if not base_ings or not target_ings:
continue
removed, added = diff_ingredients(base_ings, target_ings)
if len(removed) != 1 or len(added) != 1:
continue
original = removed[0]
substitute = added[0]
constraints = _parse_categories(row.get("categories", []))
if not constraints:
continue
for constraint in constraints:
key = (original, substitute, constraint)
pair_counts[key]["count"] += 1
def get_profile(name: str) -> dict:
row = conn.execute(
"SELECT fat_pct, moisture_pct, glutamate_mg, protein_pct "
"FROM ingredient_profiles WHERE name = ?", (name,)
).fetchone()
if row:
return {"fat": row[0] or 0, "moisture": row[1] or 0,
"glutamate": row[2] or 0, "protein": row[3] or 0}
return {"fat": 0, "moisture": 0, "glutamate": 0, "protein": 0}
print("Writing substitution pairs...")
inserted = 0
for (original, substitute, constraint), data in pair_counts.items():
if data["count"] < 3:
continue
p_orig = get_profile(original)
p_sub = get_profile(substitute)
conn.execute("""
INSERT OR REPLACE INTO substitution_pairs
(original_name, substitute_name, constraint_label,
fat_delta, moisture_delta, glutamate_delta, protein_delta,
occurrence_count, source)
VALUES (?,?,?,?,?,?,?,?,?)
""", (
original, substitute, constraint,
round(p_sub["fat"] - p_orig["fat"], 2),
round(p_sub["moisture"] - p_orig["moisture"], 2),
round(p_sub["glutamate"] - p_orig["glutamate"], 2),
round(p_sub["protein"] - p_orig["protein"], 2),
data["count"], "derived",
))
inserted += 1
conn.commit()
finally:
conn.close()
print(f"Inserted {inserted} substitution pairs (min 3 occurrences)")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--db", required=True, type=Path)
parser.add_argument("--recipepairs", required=True, type=Path,
help="pairs.parquet from lishuyang/recipepairs")
parser.add_argument("--recipepairs-recipes", required=True, type=Path,
dest="recipepairs_recipes",
help="recipes.parquet from lishuyang/recipepairs (ingredient lookup)")
args = parser.parse_args()
build(args.db, args.recipepairs, args.recipepairs_recipes)