Spaces:
Running
Running
| """ | |
| Soap Lab — Main Gradio App | |
| Uses HF Inference API with Qwen2.5-0.5B — free, no billing required. | |
| Tiny Titan eligible (0.5B parameters). | |
| """ | |
| import os | |
| import re | |
| import json | |
| import gradio as gr | |
| from huggingface_hub import InferenceClient | |
| from chemistry import ( | |
| calculate_lye, score_recipe, find_substitutes, | |
| cure_timeline, profile_summary, OILS | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Model config — Qwen2.5-0.5B is free on HF Inference API | |
| # --------------------------------------------------------------------------- | |
| client = InferenceClient( | |
| provider="featherless-ai", | |
| api_key=os.environ.get("HF_TOKEN"), | |
| ) | |
| MODEL = "Qwen/Qwen2.5-3B-Instruct" | |
| SYSTEM_PROMPT = """You are Soap Lab, a friendly expert assistant for home soap makers. | |
| You help hobbyists understand soap chemistry in plain English — no jargon without explanation. | |
| You will always receive pre-calculated numbers from a verified chemistry engine. | |
| NEVER recalculate or second-guess these numbers — they are correct. | |
| Your job is to EXPLAIN the numbers and give practical advice. | |
| Strict chemistry rules: | |
| - Never invent soap chemistry terminology. | |
| - INS score explanation must only say: higher INS = harder, faster-curing bar; achieved by increasing hard oils like coconut or palm. | |
| - Never expand the acronym "INS" — it does not stand for anything user-facing. Just call it "INS score" throughout. | |
| - Do NOT suggest specific oil substitutions to fix the INS score. | |
| - If INS is low, say exactly: "To improve your INS score, increase the proportion of hard, saturated oils such as coconut oil or palm oil in your recipe." | |
| - Do NOT name any other oils as INS-improving alternatives. | |
| - Do NOT suggest swapping coconut oil out of a recipe to fix INS — coconut oil raises INS, it should never be removed for this purpose. | |
| - Do not mention hydration, insulation, color mixing, or skin absorption in relation to INS — these are incorrect. | |
| - If uncertain about any score's chemistry, say: "For detailed guidance, consult a soap recipe calculator or soapmaking reference." Never invent a suggestion. | |
| - Never suggest adding more water or alkali/lye as a fix for any score — this is dangerous in soap making. Only suggest adjusting oil ratios or superfat percentage. | |
| Keep responses warm, encouraging, and concise. Use short paragraphs. | |
| If a recipe has issues (score too low/high), explain what that means practically | |
| and suggest one simple fix. | |
| End your response after giving your advice. Do not ask follow-up questions. Do not say 'How do you want to proceed' or similar.""" | |
| # --------------------------------------------------------------------------- | |
| # Extraction prompt (LLM -> structured JSON) | |
| # --------------------------------------------------------------------------- | |
| EXTRACTION_SYSTEM_PROMPT = """You are an extraction assistant. Your job is to extract ingredients and weights | |
| from a user's free-form text and return a single JSON object only (no explanation). | |
| Requirements: | |
| - If the user provided a recipe or ingredient list, return exactly: | |
| {"type": "recipe", "ingredients": [{"name": "<ingredient name>", "weight_g": <number>}, ...]} | |
| - If the user did NOT provide ingredients (they asked a question or for advice), return exactly: | |
| {"type": "advice"} | |
| - Normalise ALL weight formats to grams (g). Accept formats like: "300g", "300 grams", | |
| "0.3kg", "10oz", "1 lb", "1lb", "ounces", "kgs", and convert them to grams. | |
| Use 1 oz = 28.3495 g, 1 lb = 453.59237 g, 1 kg = 1000 g. | |
| - Use numeric grams (floats allowed). Round to one decimal place only when necessary. | |
| - Return only JSON; do not include any trailing text or markdown. | |
| - Use deterministic output: set sampling temperature to 0.0 (this will be enforced by the caller). | |
| Few-shot examples (input -> JSON): | |
| Input: "300g coconut oil, 200g olive oil, 100g shea butter" | |
| Output: {"type": "recipe", "ingredients": [{"name": "coconut oil", "weight_g": 300}, {"name": "olive oil", "weight_g": 200}, {"name": "shea butter", "weight_g": 100}]} | |
| Input: "0.3kg olive oil and 10oz coconut oil" | |
| Output: {"type": "recipe", "ingredients": [{"name": "olive oil", "weight_g": 300}, {"name": "coconut oil", "weight_g": 283.5}]} | |
| Input: "I have 1 lb coconut oil and 250 g olive oil" | |
| Output: {"type": "recipe", "ingredients": [{"name": "coconut oil", "weight_g": 453.6}, {"name": "olive oil", "weight_g": 250}]} | |
| Input: "How can I make a gentle soap for dry sensitive skin?" | |
| Output: {"type": "advice"} | |
| """ | |
| # --------------------------------------------------------------------------- | |
| # AI call | |
| # --------------------------------------------------------------------------- | |
| def ask_model(prompt: str, context: str) -> str: | |
| full_prompt = f"{context}\n\nUser asked: {prompt}" | |
| try: | |
| response = client.chat_completion( | |
| model=MODEL, | |
| messages=[ | |
| {"role": "system", "content": SYSTEM_PROMPT}, | |
| {"role": "user", "content": full_prompt} | |
| ], | |
| max_tokens=500, | |
| stop=["user ", "User ", "\nuser", "\nUser"], | |
| ) | |
| return response.choices[0].message.content | |
| except Exception as e: | |
| return f"Model error: {str(e)}" | |
| # --------------------------------------------------------------------------- | |
| # Parser | |
| # --------------------------------------------------------------------------- | |
| # Build a lookup that maps common name fragments to oil keys | |
| OIL_ALIASES = { | |
| "shea": "sheanut butter", | |
| "coconut": "coconut oil", | |
| "olive": "olive oil", | |
| "castor": "castor oil", | |
| "palm kernel": "palm kernel oil", | |
| "palm": "palm oil", | |
| "tallow": "tallow (beef)", | |
| "lard": "lard", | |
| "avocado": "avocado oil", | |
| "sunflower": "sunflower seed oil", | |
| "cocoa": "cocoa butter", | |
| "mango": "mango butter (refined)", | |
| "jojoba": "jojoba oil (golden, unrefined)", | |
| "almond": "almond oil, sweet", | |
| "sheanut": "sheanut butter", | |
| "hemp": "hempseed oil", | |
| "rice bran": "rice bran oil", | |
| "neem": "neem oil", | |
| } | |
| def parse_recipe(text: str) -> dict[str, float]: | |
| text_lower = text.lower() | |
| recipe = {} | |
| pattern = r'(\d+(?:\.\d+)?)\s*(?:g|grams?|gram|%)?\s+([a-z\s,()]+?)(?=\d|$|,|and\b|with\b|\n|—|-{1,2})' | |
| matches = re.findall(pattern, text_lower) | |
| for weight_str, oil_fragment in matches: | |
| weight = float(weight_str) | |
| oil_fragment = oil_fragment.strip().rstrip(',').strip() | |
| matched_key = None | |
| # Try aliases first | |
| for alias, key in OIL_ALIASES.items(): | |
| if alias in oil_fragment: | |
| matched_key = key | |
| break | |
| # Fall back to OILS keys | |
| if not matched_key: | |
| for key in OILS: | |
| if key in oil_fragment: | |
| matched_key = key | |
| break | |
| if matched_key and weight > 0: | |
| recipe[matched_key] = recipe.get(matched_key, 0) + weight | |
| return recipe | |
| def detect_mode(text: str) -> str: | |
| text_lower = text.lower() | |
| if any(w in text_lower for w in ["improve", "optimise", "optimize", "make it better", "fix my recipe"]): | |
| return "improve" | |
| if any(w in text_lower for w in ["only have", "i have", "in my cupboard", "at home", "pantry"]): | |
| return "pantry" | |
| if any(w in text_lower for w in ["failed", "wrong", "greasy", "soft", "sticky", "didn't work", "bad batch"]): | |
| return "diagnose" | |
| if any(w in text_lower for w in ["substitute", "replace", "instead of", "swap", "can't find", "cannot find"]): | |
| return "substitute" | |
| if any(w in text_lower for w in ["sensitive", "dry skin", "oily skin", "eczema", "baby", "hard bar", "gentle", "want a"]): | |
| return "goal" | |
| return "recipe" | |
| # --------------------------------------------------------------------------- | |
| # LLM extraction and JSON validation | |
| # --------------------------------------------------------------------------- | |
| def _find_json_in_text(text: str) -> str | None: | |
| m = re.search(r"\{.*\}", text, re.DOTALL) | |
| return m.group(0) if m else None | |
| def _map_ingredients_to_recipe(ingredients: list[dict]) -> dict[str, float] | None: | |
| recipe = {} | |
| for item in ingredients: | |
| name = (item.get("name") or "").lower() | |
| weight = item.get("weight_g") | |
| try: | |
| weight = float(weight) | |
| except Exception: | |
| return None | |
| if weight <= 0: | |
| return None | |
| matched_key = None | |
| for alias, key in OIL_ALIASES.items(): | |
| if alias in name: | |
| matched_key = key | |
| break | |
| if not matched_key: | |
| for key in OILS: | |
| if key in name: | |
| matched_key = key | |
| break | |
| if not matched_key: | |
| # Could not map ingredient to a known oil | |
| return None | |
| recipe[matched_key] = recipe.get(matched_key, 0.0) + weight | |
| return recipe | |
| def extract_with_llm(user_input: str) -> tuple[str, dict | None]: | |
| """Call the LLM to extract ingredients as JSON. Returns (type, recipe_or_none). | |
| type is 'recipe' or 'advice'. If extraction fails or JSON is invalid, returns ('advice', None). | |
| """ | |
| try: | |
| response = client.chat_completion( | |
| model=MODEL, | |
| messages=[ | |
| {"role": "system", "content": EXTRACTION_SYSTEM_PROMPT}, | |
| {"role": "user", "content": user_input}, | |
| ], | |
| max_tokens=400, | |
| temperature=0.0, | |
| ) | |
| content = response.choices[0].message.content | |
| except Exception: | |
| return ("advice", None) | |
| # Try to extract JSON from the model output | |
| json_text = None | |
| try: | |
| json_text = _find_json_in_text(content) or content | |
| parsed = json.loads(json_text) | |
| except Exception: | |
| return ("advice", None) | |
| if not isinstance(parsed, dict) or "type" not in parsed: | |
| return ("advice", None) | |
| if parsed["type"] == "advice": | |
| return ("advice", None) | |
| if parsed["type"] == "recipe": | |
| ingredients = parsed.get("ingredients") | |
| if not isinstance(ingredients, list): | |
| return ("advice", None) | |
| mapped = _map_ingredients_to_recipe(ingredients) | |
| if not mapped: | |
| return ("advice", None) | |
| return ("recipe", mapped) | |
| return ("advice", None) | |
| # --------------------------------------------------------------------------- | |
| # Mode handlers | |
| # --------------------------------------------------------------------------- | |
| def handle_recipe(user_input: str, vegan_only: bool, superfat: float = 5.0) -> tuple[str, str, str]: | |
| recipe = parse_recipe(user_input) | |
| if not recipe: | |
| return ( | |
| "⚠️ Couldn't find any oils and weights in your input.", | |
| "", | |
| "Try something like: '300g coconut oil, 200g olive oil, 100g shea butter'" | |
| ) | |
| lye = calculate_lye(recipe, superfat=superfat, water_ratio=0.38) | |
| if "error" in lye: | |
| return f"⚠️ {lye['error']}", "", "" | |
| scores = score_recipe(recipe) | |
| profile = profile_summary(recipe) | |
| cure = cure_timeline(recipe) | |
| lye_text = f"""**Lye Calculation** *(from verified SAP values — not AI-generated)* | |
| 🧪 Total oil weight: {lye['total_oil_weight_g']}g | |
| 💧 Water: {lye['water_g']}g | |
| ⚗️ NaOH (lye): **{lye['naoh_g']}g** | |
| 🧴 Superfat: {superfat}% | |
| **Per oil:** | |
| """ | |
| for oil in lye["oil_details"]: | |
| lye_text += f"• {oil['name']}: {oil['weight_g']}g → {oil['naoh_needed_g']}g NaOH (SAP: {oil['naoh_sap']})\n" | |
| if not profile["is_vegan"]: | |
| lye_text += f"\n⚠️ Non-vegan oils: {', '.join(profile['non_vegan_oils'])}" | |
| status_emoji = {"ok": "✅", "low": "⬇️", "high": "⬆️"} | |
| status_label = {"ok": "In range", "low": "Low", "high": "High"} | |
| scores_text = "<h3>Soap Quality Scores</h3><p class='score-note'>Target ranges shown</p>" | |
| for key, s in scores.items(): | |
| emoji = status_emoji[s["status"]] | |
| percent = max(0, min(100, float(s["value"]))) | |
| scores_text += ( | |
| "<div class='score-row'>" | |
| "<div class='score-head'>" | |
| f"<span>{emoji} {s['label']}</span>" | |
| f"<strong>{s['value']}</strong>" | |
| "</div>" | |
| "<div class='score-track'>" | |
| f"<div class='score-fill score-{s['status']}' style='width: {percent}%;'></div>" | |
| "</div>" | |
| "<div class='score-meta'>" | |
| f"<span>Target {s['target_low']}–{s['target_high']}</span>" | |
| f"<span>{status_label[s['status']]}</span>" | |
| "</div>" | |
| "</div>" | |
| ) | |
| scores_text += f"<p class='cure-note'>⏱️ Recommended cure time: <strong>{cure['recommended_cure_label']}</strong></p>" | |
| context = f"""The user has a soap recipe with these results: | |
| Oils: {', '.join(f"{v}g {k}" for k, v in recipe.items())} | |
| NaOH needed: {lye['naoh_g']}g | |
| Water: {lye['water_g']}g | |
| Superfat: 5% | |
| Quality scores: | |
| {chr(10).join(f"- {s['label']}: {s['value']} (target {s['target_low']}-{s['target_high']}, status: {s['status']})" for s in scores.values())} | |
| Cure recommendation: {cure['recommended_cure_label']} — {cure['reason']} | |
| Is vegan: {profile['is_vegan']} | |
| Explain these results in plain English. Comment on any scores that are out of range. | |
| Give one specific practical tip to improve the recipe if needed. Be warm and encouraging. | |
| End with what to expect when curing.""" | |
| explanation = ask_model(user_input, context) | |
| return lye_text, scores_text, explanation | |
| IMPROVE_SCORE_KEYS = ("hardness", "cleansing", "conditioning", "ins", "iodine_value") | |
| def _score_status_map(scores: dict) -> dict[str, dict]: | |
| return {key: scores[key] for key in IMPROVE_SCORE_KEYS if key in scores} | |
| def _score_distance(score: dict) -> float: | |
| if score["status"] == "low": | |
| return score["target_low"] - score["value"] | |
| if score["status"] == "high": | |
| return score["value"] - score["target_high"] | |
| return 0.0 | |
| def _choose_oil(recipe: dict[str, float], candidates: list[str], fallback: str | None = None) -> str | None: | |
| for key in candidates: | |
| if key in recipe and recipe[key] > 0: | |
| return key | |
| if fallback and fallback in recipe and recipe[fallback] > 0: | |
| return fallback | |
| for key, weight in sorted(recipe.items(), key=lambda item: item[1], reverse=True): | |
| if weight > 0: | |
| return key | |
| return None | |
| def _transfer_oil(recipe: dict[str, float], source: str | None, target: str | None, amount: float) -> str: | |
| if amount <= 0 or not target or (source and source == target): | |
| return "" | |
| if target not in recipe: | |
| recipe[target] = 0.0 | |
| if source and source in recipe and recipe[source] > 0: | |
| moved = min(amount, recipe[source]) | |
| if moved <= 0: | |
| return "" | |
| recipe[source] -= moved | |
| recipe[target] += moved | |
| if recipe[source] <= 1e-9: | |
| recipe.pop(source, None) | |
| return f"{source} -> {target} ({moved:.1f}g)" | |
| recipe[target] += amount | |
| return f"+ {target} ({amount:.1f}g)" | |
| def _apply_improvement_rules(recipe: dict[str, float], scores: dict, step_amount: float) -> list[str]: | |
| actions = [] | |
| cleansing = scores.get("cleansing") | |
| if cleansing and cleansing["status"] == "high": | |
| action = _transfer_oil( | |
| recipe, | |
| _choose_oil(recipe, ["coconut oil", "palm kernel oil"], "coconut oil"), | |
| _choose_oil(recipe, ["olive oil"], "olive oil"), | |
| step_amount, | |
| ) | |
| if action: | |
| actions.append(f"Cleansing too high: {action}") | |
| conditioning = scores.get("conditioning") | |
| if conditioning and conditioning["status"] == "low": | |
| action = _transfer_oil( | |
| recipe, | |
| _choose_oil(recipe, ["coconut oil"], "coconut oil"), | |
| _choose_oil(recipe, ["castor oil", "sheanut butter"], "castor oil"), | |
| step_amount, | |
| ) | |
| if action: | |
| actions.append(f"Conditioning too low: {action}") | |
| hardness = scores.get("hardness") | |
| if hardness and hardness["status"] == "low": | |
| action = _transfer_oil( | |
| recipe, | |
| _choose_oil(recipe, ["olive oil", "sunflower seed oil", "safflower oil"], "olive oil"), | |
| _choose_oil(recipe, ["coconut oil", "tallow (beef)"], "coconut oil"), | |
| step_amount, | |
| ) | |
| if action: | |
| actions.append(f"Hardness too low: {action}") | |
| ins = scores.get("ins") | |
| cleansing = scores.get("cleansing") | |
| # Only fix INS if cleansing is not already being fixed (they conflict) | |
| if ins and ins["status"] == "low" and not (cleansing and cleansing["status"] == "high"): | |
| action = _transfer_oil( | |
| recipe, | |
| _choose_oil(recipe, ["sunflower seed oil", "safflower oil", "olive oil"], "olive oil"), | |
| _choose_oil(recipe, ["coconut oil", "tallow (beef)"], "coconut oil"), | |
| step_amount, | |
| ) | |
| if action: | |
| actions.append(f"INS too low: {action}") | |
| return actions | |
| def _comparison_arrow(before: dict, after: dict) -> tuple[str, str]: | |
| before_value = before["value"] | |
| after_value = after["value"] | |
| before_status = before["status"] | |
| after_status = after["status"] | |
| if after_status == "ok" and before_status != "ok": | |
| return "✅", "now in range" | |
| if before_status == "ok" and after_status == "ok": | |
| return "✅", "stays in range" | |
| before_delta = _score_distance(before) | |
| after_delta = _score_distance(after) | |
| if after_delta < before_delta: | |
| return "⬆️", "improved" | |
| if after_delta > before_delta: | |
| return "⬇️", "got worse" | |
| if after_value > before_value: | |
| return ("⬆️", "improved") if after_status == "low" else ("⬇️", "got worse") | |
| if after_value < before_value: | |
| return ("⬆️", "improved") if after_status == "high" else ("⬇️", "got worse") | |
| return "✅", "unchanged" | |
| def _format_recipe_weights(recipe: dict[str, float]) -> str: | |
| lines = [] | |
| for key, weight in sorted(recipe.items(), key=lambda item: item[0]): | |
| if weight > 0: | |
| lines.append(f"• {OILS[key].name}: {weight:.1f}g") | |
| return "\n".join(lines) | |
| def _format_improve_comparison(before_scores: dict, after_scores: dict, recipe: dict[str, float], iterations: int, actions: list[str]) -> str: | |
| comparison = ["**Recipe Improvement Comparison**", ""] | |
| comparison.append(f"Ran **{iterations}** improvement iteration{'s' if iterations != 1 else ''}.") | |
| if actions: | |
| comparison.append("") | |
| comparison.append("Changes applied:") | |
| for action in actions: | |
| comparison.append(f"- {action}") | |
| comparison.append("") | |
| comparison.append("| Score | Before | After |") | |
| comparison.append("| --- | ---: | ---: |") | |
| for key, label in [ | |
| ("hardness", "Hardness"), | |
| ("cleansing", "Cleansing"), | |
| ("conditioning", "Conditioning"), | |
| ("ins", "INS"), | |
| ("iodine_value", "Iodine value"), | |
| ("bubbly_lather", "Bubbly lather"), | |
| ("creamy_lather", "Creamy lather"), | |
| ]: | |
| before = before_scores[key] | |
| after = after_scores[key] | |
| arrow, text = _comparison_arrow(before, after) | |
| comparison.append( | |
| f"| {label} | {before['value']} ({before['status']}) | {after['value']} ({after['status']}) {arrow} {text} |" | |
| ) | |
| comparison.append("") | |
| comparison.append("Final recipe:") | |
| comparison.append(_format_recipe_weights(recipe)) | |
| return "\n".join(comparison) | |
| def handle_improve(user_input: str, vegan_only: bool, superfat: float = 5.0) -> tuple[str, str, str]: | |
| recipe = parse_recipe(user_input) | |
| if not recipe: | |
| return ( | |
| "⚠️ Couldn't find any oils and weights in your input.", | |
| "", | |
| "Try something like: '300g coconut oil, 200g olive oil, 100g shea butter'", | |
| ) | |
| current_recipe = dict(recipe) | |
| initial_scores = score_recipe(current_recipe) | |
| if not initial_scores: | |
| return ( | |
| "⚠️ I couldn't score that recipe.", | |
| "", | |
| "Please try a recipe with at least one recognised oil.", | |
| ) | |
| actions: list[str] = [] | |
| iterations = 0 | |
| for _ in range(3): | |
| scores = score_recipe(current_recipe) | |
| if not scores: | |
| break | |
| relevant_scores = _score_status_map(scores) | |
| failing = [scores[key] for key in IMPROVE_SCORE_KEYS if scores[key]["status"] != "ok"] | |
| if not failing: | |
| break | |
| step_amount = max(sum(current_recipe.values()) * 0.05, 1.0) | |
| iteration_actions = _apply_improvement_rules(current_recipe, relevant_scores, step_amount) | |
| if not iteration_actions: | |
| break | |
| iterations += 1 | |
| actions.extend(iteration_actions) | |
| final_scores = score_recipe(current_recipe) | |
| if not final_scores: | |
| return ( | |
| "⚠️ The improved recipe could not be scored.", | |
| "", | |
| "The adjustment loop ran, but the chemistry engine could not evaluate the result.", | |
| ) | |
| lye = calculate_lye(current_recipe, superfat=superfat, water_ratio=0.38) | |
| if "error" in lye: | |
| return f"⚠️ {lye['error']}", "", "" | |
| comparison_text = _format_improve_comparison(initial_scores, final_scores, current_recipe, iterations, actions) | |
| lye_text = f"""**Improved Lye Calculation** *(from verified SAP values — not AI-generated)* | |
| 🧪 Total oil weight: {lye['total_oil_weight_g']}g | |
| 💧 Water: {lye['water_g']}g | |
| ⚗️ NaOH (lye): **{lye['naoh_g']}g** | |
| 🧴 Superfat: {superfat}% | |
| **Improved recipe:** | |
| {_format_recipe_weights(current_recipe)} | |
| **Per oil:** | |
| """ | |
| for oil in lye["oil_details"]: | |
| lye_text += f"• {oil['name']}: {oil['weight_g']}g → {oil['naoh_needed_g']}g NaOH (SAP: {oil['naoh_sap']})\n" | |
| profile = profile_summary(current_recipe) | |
| cure = cure_timeline(current_recipe) | |
| if profile and not profile["is_vegan"]: | |
| lye_text += f"\n⚠️ Non-vegan oils: {', '.join(profile['non_vegan_oils'])}" | |
| if cure: | |
| lye_text += f"\n\n⏱️ Recommended cure time: **{cure['recommended_cure_label']}**" | |
| context = f"""A soap maker asked to improve this recipe: "{user_input}" | |
| The recipe was automatically adjusted using deterministic chemistry rules up to 3 iterations. | |
| Original scores: | |
| {chr(10).join(f"- {s['label']}: {s['value']} (target {s['target_low']}-{s['target_high']}, status: {s['status']})" for s in initial_scores.values())} | |
| Final scores: | |
| {chr(10).join(f"- {s['label']}: {s['value']} (target {s['target_low']}-{s['target_high']}, status: {s['status']})" for s in final_scores.values())} | |
| Changed recipe: | |
| {_format_recipe_weights(current_recipe)} | |
| Explain in plain English what changed, which problems were being targeted, and why the new recipe is better. | |
| Be concise, practical, and encouraging.""" | |
| explanation = ask_model(user_input, context) | |
| return comparison_text, lye_text, explanation | |
| def handle_substitute(user_input: str, vegan_only: bool) -> tuple[str, str, str]: | |
| text_lower = user_input.lower() | |
| target_key = None | |
| for key in OILS: | |
| if key in text_lower: | |
| target_key = key | |
| break | |
| if not target_key: | |
| for key in OILS: | |
| key_words = [w for w in key.split() if len(w) > 3 and w not in ["oil", "butter", "fat"]] | |
| if any(w in text_lower for w in key_words): | |
| target_key = key | |
| break | |
| if not target_key: | |
| return "⚠️ Couldn't identify which oil you want to substitute.", "", "Try: 'I can't find palm oil, what can I use instead?'" | |
| subs = find_substitutes(target_key, n=3, vegan_only=vegan_only) | |
| oil = OILS[target_key] | |
| subs_text = f"**Substitutes for {oil.name}**\n*(ranked by fatty acid similarity)*\n\n" | |
| for i, sub in enumerate(subs, 1): | |
| vegan_flag = "" if sub["vegan"] else " 🐄" | |
| subs_text += f"{i}. **{sub['name']}**{vegan_flag} — SAP: {sub['naoh_sap']}, similarity score: {100 - sub['distance']:.0f}/100\n" | |
| context = f"""User can't find {oil.name} and needs a substitute. | |
| {oil.name} fatty acid profile: | |
| - Lauric: {oil.lauric}%, Myristic: {oil.myristic}% | |
| - Palmitic: {oil.palmitic}%, Stearic: {oil.stearic}% | |
| - Oleic: {oil.oleic}%, Linoleic: {oil.linoleic}% | |
| - Ricinoleic: {oil.ricinoleic}% | |
| Best substitutes found: | |
| {chr(10).join(f"- {s['name']}: distance {s['distance']}" for s in subs)} | |
| Explain WHY each substitute works or doesn't, what changes in the soap (lather, hardness, conditioning), | |
| and which one you'd recommend for a typical home recipe. Mention that their lye amount will change slightly. | |
| Keep it practical and friendly.""" | |
| explanation = ask_model(user_input, context) | |
| return subs_text, "", explanation | |
| def handle_goal(user_input: str, vegan_only: bool) -> tuple[str, str, str]: | |
| context = f"""A home soap maker has described what they want: "{user_input}" | |
| Based on their goal, suggest a simple beginner recipe using common oils. | |
| Format your response as: | |
| 1. A recipe (oils + approximate percentages that add to 100%, for a 500g batch) | |
| 2. Why each oil earns its place (1 sentence each) | |
| 3. What skin type this suits | |
| 4. One thing to watch out for | |
| Use only common oils: coconut oil, olive oil, castor oil, shea butter, cocoa butter, | |
| sunflower seed oil, avocado oil, palm oil, lard, tallow. | |
| {"Use only plant-based oils (no lard, tallow, beeswax)." if vegan_only else ""} | |
| Keep it warm and encouraging.""" | |
| suggestion = ask_model(user_input, context) | |
| return "", "", suggestion | |
| def handle_diagnose(user_input: str, vegan_only: bool) -> tuple[str, str, str]: | |
| context = f"""A home soap maker had a batch problem: "{user_input}" | |
| Diagnose the most likely causes based on their description. | |
| Cover: | |
| 1. Most likely cause (be specific — high water? wrong superfat? trace issues? wrong oils?) | |
| 2. How to tell if that's really the problem | |
| 3. How to fix it in their next batch | |
| 4. Whether the soap is still usable or should be rebatched | |
| Be direct and practical. Don't be alarmist. Most soap problems are fixable.""" | |
| diagnosis = ask_model(user_input, context) | |
| return "", "", diagnosis | |
| def handle_pantry(user_input: str, vegan_only: bool) -> tuple[str, str, str]: | |
| recipe_oils = [] | |
| for key in OILS: | |
| if vegan_only and not OILS[key].vegan: | |
| continue | |
| key_words = [w for w in key.split() if len(w) > 3 and w not in ["oil", "butter", "fat"]] | |
| if any(w in user_input.lower() for w in key_words): | |
| recipe_oils.append(OILS[key].name) | |
| if not recipe_oils: | |
| return "⚠️ Couldn't spot any oils in your message.", "", "Try: 'I have coconut oil, olive oil and shea butter — what can I make?'" | |
| context = f"""A soap maker only has these oils available: {', '.join(recipe_oils)} | |
| {"They want a vegan recipe." if vegan_only else ""} | |
| User message: "{user_input}" | |
| Suggest the best soap recipe using only (some or all of) these oils. | |
| Give: | |
| 1. Recommended percentages for a 500g batch | |
| 2. Why this combination works | |
| 3. What the soap will be like (lather, hardness, skin feel) | |
| 4. Any oils from their list you'd leave out and why | |
| Be practical and encouraging.""" | |
| suggestion = ask_model(user_input, context) | |
| return "", "", suggestion | |
| # --------------------------------------------------------------------------- | |
| # Main router | |
| # --------------------------------------------------------------------------- | |
| def water_hardness_recommendation(water_hardness: str, oil_weight_g: float | None = None) -> str: | |
| hardness = (water_hardness or "Soft").strip().lower() | |
| dose_text = "1 tsp sodium citrate per 500g oils" | |
| if oil_weight_g and oil_weight_g > 0: | |
| tsp_amount = oil_weight_g / 500 | |
| dose_text = f"{tsp_amount:.1f} tsp sodium citrate for {oil_weight_g:.0f}g oils" | |
| if hardness == "hard": | |
| return f"""**Water Hardness Recommendation** | |
| **Hard water:** add **{dose_text}**. | |
| Sodium citrate binds the calcium and magnesium minerals in hard water, which helps reduce soap scum and keeps more soap available for lather. Dissolve it fully in your water before adding lye.""" | |
| if hardness == "medium": | |
| return f"""**Water Hardness Recommendation** | |
| **Medium water:** sodium citrate is optional. Use **{dose_text}** if you notice dull lather, residue, or soap scum. | |
| It helps by tying up moderate mineral content before those minerals can react with finished soap.""" | |
| return """**Water Hardness Recommendation** | |
| **Soft water:** no adjustment needed. | |
| Your water should not meaningfully interfere with lather or leave extra mineral soap residue.""" | |
| def run_soap_lab(user_input: str, vegan_only: bool, superfat: float = 5.0, water_hardness: str = "Soft") -> tuple[str, str, str, str]: | |
| # First, ask the LLM to extract a structured recipe JSON (temperature=0.0) | |
| extraction_type, extracted = extract_with_llm(user_input) | |
| if extraction_type == "advice": | |
| # Extraction determined this is an advice request or extraction failed. | |
| advice_context = "The user did not provide a parseable recipe. Provide concise, practical soap-making advice based on their question." | |
| advice = ask_model(user_input, advice_context) | |
| return "", "", advice, water_hardness_recommendation(water_hardness, None) | |
| parsed_recipe = extracted or {} | |
| total_oil_weight = sum(parsed_recipe.values()) if parsed_recipe else None | |
| water_panel = water_hardness_recommendation(water_hardness, total_oil_weight) | |
| if not user_input.strip(): | |
| return "", "", "Type your question or recipe above and hit Submit!", water_panel | |
| mode = detect_mode(user_input) | |
| if mode == "improve": | |
| comparison, lye_text, explanation = handle_improve(user_input, vegan_only, superfat) | |
| return lye_text, comparison, explanation, water_panel | |
| if mode == "substitute": | |
| return (*handle_substitute(user_input, vegan_only), water_panel) | |
| elif mode == "goal": | |
| return (*handle_goal(user_input, vegan_only), water_panel) | |
| elif mode == "diagnose": | |
| return (*handle_diagnose(user_input, vegan_only), water_panel) | |
| elif mode == "pantry": | |
| return (*handle_pantry(user_input, vegan_only), water_panel) | |
| else: | |
| return (*handle_recipe(user_input, vegan_only, superfat), water_panel) | |
| # --------------------------------------------------------------------------- | |
| # Gradio UI | |
| # --------------------------------------------------------------------------- | |
| EXAMPLES = [ | |
| ["300g coconut oil, 200g olive oil, 100g shea butter — is this a good recipe?", False], | |
| ["300g coconut oil, 400g olive oil — optimise this recipe for me", False], | |
| ["I want to make a gentle soap for my mum who has dry skin", False], | |
| ["I can't find palm oil, what can I substitute it with?", False], | |
| ["I only have coconut oil and olive oil at home, what can I make?", False], | |
| ["My soap came out really greasy and soft after 4 weeks, what went wrong?", False], | |
| ["I want a hard bar with big fluffy bubbles", False], | |
| ] | |
| CSS = """ | |
| @import url('https://fonts.googleapis.com/css2?family=Caveat:wght@600;700&display=swap'); | |
| :root { | |
| --soap-cream: #FDF6EC; | |
| --soap-cream-deep: #F7E8CD; | |
| --soap-amber: #D4A853; | |
| --soap-amber-dark: #A97822; | |
| --soap-ink: #3D2B1F; | |
| --soap-sage: #7D9467; | |
| --soap-clay: #B86F4B; | |
| --soap-paper: #FFF9EF; | |
| } | |
| body, | |
| .gradio-container, | |
| .main, | |
| .wrap { | |
| background: var(--soap-cream) !important; | |
| color: var(--soap-ink) !important; | |
| } | |
| body { | |
| padding-top: 24px !important; | |
| } | |
| .gradio-container { | |
| width: 100% !important; | |
| max-width: 960px !important; | |
| margin: auto; | |
| padding: 0 16px 36px !important; | |
| font-family: Georgia, "Times New Roman", serif; | |
| } | |
| #soap-shell { | |
| border: 0 !important; | |
| border-radius: 0 !important; | |
| padding: 16px !important; | |
| background: var(--soap-cream) !important; | |
| box-shadow: none !important; | |
| } | |
| #soap-header { | |
| background-color: #FDF6EC !important; | |
| } | |
| #soap-header, | |
| .header-panel { | |
| background: #FDF6EC !important; | |
| background-color: #FDF6EC !important; | |
| border: 0 !important; | |
| border-bottom: 3px solid #D4A853 !important; | |
| border-radius: 0 !important; | |
| padding: 0 0 18px !important; | |
| margin-bottom: 24px !important; | |
| box-shadow: none !important; | |
| } | |
| #soap-header *, | |
| .header-panel *, | |
| #soap-header .block, | |
| #soap-header .form, | |
| .header-panel .block, | |
| .header-panel .form { | |
| background-color: #FDF6EC !important; | |
| } | |
| #title { | |
| text-align: center; | |
| margin: 0 0 4px; | |
| font-family: "Caveat", cursive; | |
| font-size: clamp(56px, 8vw, 86px); | |
| line-height: 0.9; | |
| color: var(--soap-ink); | |
| } | |
| #subtitle { | |
| text-align: center; | |
| color: #7B5B34; | |
| margin: 0; | |
| font-size: 16px; | |
| } | |
| .input-panel { | |
| background: rgba(255, 249, 239, 0.76); | |
| border: 1.5px solid rgba(212, 168, 83, 0.48); | |
| border-radius: 16px; | |
| padding: 16px; | |
| box-shadow: 0 8px 20px rgba(61, 43, 31, 0.08); | |
| } | |
| .input-panel textarea, | |
| .input-panel input:not([type="checkbox"]), | |
| .input-panel select { | |
| background: #FFFDF8 !important; | |
| border-color: rgba(212, 168, 83, 0.62) !important; | |
| color: var(--soap-ink) !important; | |
| border-radius: 12px !important; | |
| } | |
| .input-panel input[type="checkbox"] { | |
| pointer-events: auto !important; | |
| z-index: 999 !important; | |
| position: relative !important; | |
| width: 18px !important; | |
| height: 18px !important; | |
| } | |
| .input-panel textarea { | |
| font-size: 14px !important; | |
| } | |
| .input-panel label, | |
| .input-panel span, | |
| .input-panel .label-wrap { | |
| color: var(--soap-ink) !important; | |
| } | |
| #calculate-btn { | |
| width: 100%; | |
| } | |
| #calculate-btn button, | |
| #calculate-btn { | |
| border-radius: 14px !important; | |
| } | |
| #calculate-btn button { | |
| width: 100% !important; | |
| min-height: 52px; | |
| background: linear-gradient(180deg, #E1BB62, var(--soap-amber)) !important; | |
| border: 1px solid var(--soap-amber-dark) !important; | |
| color: var(--soap-ink) !important; | |
| font-weight: 700 !important; | |
| box-shadow: 0 8px 16px rgba(169, 120, 34, 0.28); | |
| } | |
| .notebook-card { | |
| background: linear-gradient(180deg, var(--soap-paper), var(--soap-cream)); | |
| border-radius: 14px; | |
| padding: 18px; | |
| color: var(--soap-ink); | |
| box-shadow: 0 10px 24px rgba(61, 43, 31, 0.12); | |
| min-height: 180px; | |
| } | |
| .lye-box { | |
| border: 1.5px solid var(--soap-amber); | |
| border-left: 7px solid var(--soap-amber); | |
| } | |
| .scores-box { | |
| border: 1.5px solid var(--soap-sage); | |
| border-left: 7px solid var(--soap-sage); | |
| } | |
| .explain-box { | |
| border: 1.5px solid var(--soap-clay); | |
| border-left: 7px solid var(--soap-clay); | |
| margin-top: 16px; | |
| } | |
| .water-box { | |
| border: 1.5px solid #4F8E9E; | |
| border-left: 7px solid #4F8E9E; | |
| margin-top: 16px; | |
| } | |
| .notebook-card h3, | |
| .notebook-card h2, | |
| .notebook-card strong { | |
| color: var(--soap-ink); | |
| } | |
| .score-note { | |
| margin: -8px 0 14px; | |
| color: #7B5B34; | |
| font-size: 13px; | |
| } | |
| .score-row { | |
| margin: 0 0 14px; | |
| } | |
| .score-head, | |
| .score-meta { | |
| display: flex; | |
| justify-content: space-between; | |
| gap: 12px; | |
| align-items: center; | |
| } | |
| .score-head { | |
| font-size: 15px; | |
| } | |
| .score-meta { | |
| margin-top: 5px; | |
| color: #7B5B34; | |
| font-size: 12px; | |
| } | |
| .score-track { | |
| height: 10px; | |
| margin-top: 7px; | |
| background: #EFE0C7; | |
| border-radius: 999px; | |
| overflow: hidden; | |
| box-shadow: inset 0 1px 2px rgba(61, 43, 31, 0.18); | |
| } | |
| .score-fill { | |
| height: 100%; | |
| border-radius: inherit; | |
| } | |
| .score-ok { background: linear-gradient(90deg, #8FAE72, #B8C98F); } | |
| .score-low { background: linear-gradient(90deg, #D4A853, #E7C779); } | |
| .score-high { background: linear-gradient(90deg, #B86F4B, #D5966D); } | |
| .cure-note { | |
| margin: 16px 0 0; | |
| padding-top: 12px; | |
| border-top: 1px dashed rgba(61, 43, 31, 0.24); | |
| } | |
| .section-divider { | |
| height: 1px; | |
| margin: 24px 0 18px; | |
| background: transparent !important; | |
| } | |
| .output-row { | |
| background: transparent !important; | |
| padding: 16px 0 !important; | |
| } | |
| .output-row > *, | |
| .output-row .column, | |
| .output-row .form, | |
| .output-row .block { | |
| background: transparent !important; | |
| } | |
| .recipe-card-grid { | |
| margin: 18px 0 20px; | |
| background: transparent !important; | |
| } | |
| .recipe-card-grid .form, | |
| .recipe-card-grid .block { | |
| background: transparent !important; | |
| } | |
| .recipe-card-row { | |
| gap: 12px !important; | |
| margin-bottom: 12px !important; | |
| background: transparent !important; | |
| } | |
| .recipe-card { | |
| width: 100%; | |
| } | |
| .recipe-card button { | |
| width: 100% !important; | |
| min-height: 70px !important; | |
| height: auto !important; | |
| padding: 12px 14px !important; | |
| border: 1.5px solid var(--soap-amber-dark) !important; | |
| border-radius: 12px !important; | |
| background: #D4A853 !important; | |
| color: var(--soap-ink) !important; | |
| box-shadow: 0 6px 14px rgba(169, 120, 34, 0.18); | |
| font-family: Georgia, "Times New Roman", serif !important; | |
| font-size: 14px !important; | |
| line-height: 1.35 !important; | |
| text-align: left !important; | |
| white-space: normal !important; | |
| overflow: visible !important; | |
| text-overflow: unset !important; | |
| } | |
| .recipe-card button span, | |
| .recipe-card button div, | |
| .recipe-card button p { | |
| white-space: normal !important; | |
| overflow: visible !important; | |
| max-width: none !important; | |
| text-overflow: unset !important; | |
| } | |
| .recipe-card button:hover { | |
| transform: translateY(-2px); | |
| background: #DEB766 !important; | |
| box-shadow: 0 10px 20px rgba(169, 120, 34, 0.24); | |
| } | |
| .safety-note { | |
| text-align: center; | |
| color: #7B5B34; | |
| font-size: 12px; | |
| margin-top: 18px; | |
| } | |
| footer, | |
| .built-with, | |
| .show-api { | |
| display: none !important; | |
| } | |
| @media (max-width: 720px) { | |
| .gradio-container { | |
| padding: 0 12px 24px !important; | |
| } | |
| #soap-shell { | |
| padding: 16px !important; | |
| border-radius: 0 !important; | |
| } | |
| #subtitle { | |
| font-size: 14px; | |
| } | |
| .recipe-card-row { | |
| gap: 10px !important; | |
| } | |
| .notebook-card { | |
| min-height: auto; | |
| padding: 15px; | |
| } | |
| .score-head, | |
| .score-meta { | |
| align-items: flex-start; | |
| } | |
| } | |
| """ | |
| soap_theme = gr.themes.Base( | |
| primary_hue="amber", | |
| secondary_hue="orange", | |
| neutral_hue="stone", | |
| ).set( | |
| body_background_fill="#FDF6EC", | |
| body_text_color="#3D2B1F", | |
| button_primary_background_fill="#D4A853", | |
| button_primary_background_fill_hover="#C79637", | |
| button_primary_text_color="#3D2B1F", | |
| block_background_fill="#FFF9EF", | |
| block_border_color="#D4A853", | |
| input_background_fill="#FFFDF8", | |
| ) | |
| def make_example_loader(example_text: str): | |
| def load_example() -> str: | |
| return example_text | |
| return load_example | |
| with gr.Blocks(title="🧼 Soap Lab") as demo: | |
| with gr.Group(elem_id="soap-shell"): | |
| with gr.Group(elem_id="soap-header", elem_classes=["header-panel"]): | |
| gr.HTML("<h1 id='title'>🧼 Soap Lab</h1>") | |
| gr.HTML("<p id='subtitle'>Plain English soap chemistry — recipes, substitutions, troubleshooting</p>") | |
| with gr.Row(elem_classes=["input-panel"]): | |
| with gr.Column(scale=4): | |
| user_input = gr.Textbox( | |
| label="What do you need help with?", | |
| placeholder="e.g. 300g coconut oil, 200g olive oil, 100g shea butter", | |
| lines=4, | |
| ) | |
| with gr.Column(scale=1): | |
| vegan_toggle = gr.Checkbox(label="🌿 Plant-based only", value=False) | |
| superfat_slider = gr.Slider( | |
| minimum=0, maximum=20, value=5, step=1, | |
| label="🧴 Superfat %", | |
| info="Higher = more moisturising, less cleansing" | |
| ) | |
| water_hardness_dropdown = gr.Dropdown( | |
| choices=["Soft", "Medium", "Hard"], | |
| value="Soft", | |
| label="💧 Water hardness", | |
| ) | |
| submit_btn = gr.Button("Calculate →", variant="primary", size="lg", elem_id="calculate-btn") | |
| recipe_buttons = [] | |
| with gr.Group(elem_classes=["recipe-card-grid"]): | |
| for row_start in range(0, len(EXAMPLES), 2): | |
| with gr.Row(elem_classes=["recipe-card-row"]): | |
| for example_text, _ in EXAMPLES[row_start:row_start + 2]: | |
| recipe_buttons.append( | |
| gr.Button( | |
| example_text, | |
| variant="secondary", | |
| elem_classes=["recipe-card"], | |
| ) | |
| ) | |
| gr.HTML("<div class='section-divider'></div>") | |
| with gr.Row(elem_classes=["output-row"]): | |
| with gr.Column(): | |
| lye_output = gr.Markdown( | |
| elem_classes=["notebook-card", "lye-box"], | |
| value="*Your lye calculation will appear here...*", | |
| ) | |
| with gr.Column(): | |
| scores_output = gr.Markdown( | |
| elem_classes=["notebook-card", "scores-box"], | |
| value="*Quality scores will appear here...*", | |
| ) | |
| explanation_output = gr.Markdown( | |
| elem_classes=["notebook-card", "explain-box"], | |
| value="*Your explanation will appear here...*" | |
| ) | |
| water_output = gr.Markdown( | |
| elem_classes=["notebook-card", "water-box"], | |
| value="*Water hardness recommendation will appear here...*" | |
| ) | |
| gr.HTML(""" | |
| <div class='safety-note'> | |
| Lye calculations use verified SAP values from the HSCG — not AI-generated.<br> | |
| Always double-check amounts before handling lye. Wear gloves and eye protection. | |
| </div> | |
| """) | |
| submit_btn.click( | |
| fn=run_soap_lab, | |
| inputs=[user_input, vegan_toggle, superfat_slider, water_hardness_dropdown], | |
| outputs=[lye_output, scores_output, explanation_output, water_output], | |
| ) | |
| user_input.submit( | |
| fn=run_soap_lab, | |
| inputs=[user_input, vegan_toggle, superfat_slider, water_hardness_dropdown], | |
| outputs=[lye_output, scores_output, explanation_output, water_output], | |
| ) | |
| for recipe_button, example in zip(recipe_buttons, EXAMPLES): | |
| recipe_button.click( | |
| fn=make_example_loader(example[0]), | |
| inputs=[], | |
| outputs=user_input, | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch(css=CSS, theme=soap_theme) | |