Expand Doris Kitchen import, failures, and tag curation

This commit is contained in:
Fizzlepoof
2026-05-17 04:07:05 +00:00
parent e2470ff7c9
commit 165e772946
18 changed files with 1268 additions and 80 deletions

View File

@@ -1,6 +1,9 @@
from __future__ import annotations
import json
import mimetypes
import subprocess
import uuid
from typing import Any
from urllib import error, parse, request
@@ -44,6 +47,69 @@ class KitchenOwlService:
parsed = raw
return exc.code, parsed
def _fetch_binary(self, url: str, timeout: int = 45) -> tuple[bytes, str]:
req = request.Request(
url,
headers={
"User-Agent": "Mozilla/5.0 (Doris Kitchen)",
"Accept": "image/*,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
},
)
try:
with request.urlopen(req, timeout=timeout) as resp:
mime_type = resp.headers.get_content_type() or "application/octet-stream"
return resp.read(), mime_type
except error.HTTPError as exc:
if exc.code not in {403, 406, 429}:
raise
curl = subprocess.run(
[
"curl", "-fsSL", "--max-time", str(timeout),
"-A", "Mozilla/5.0 (Doris Kitchen)",
"-H", "Accept: image/*,*/*;q=0.8",
"-H", "Accept-Language: en-US,en;q=0.9",
url,
],
capture_output=True,
check=False,
)
if curl.returncode != 0:
raise RuntimeError(f"Failed to fetch image URL: {url}")
guessed = mimetypes.guess_type(url)[0] or "application/octet-stream"
return curl.stdout, guessed
def _upload_photo_from_url(self, photo_url: str) -> str | None:
photo_url = str(photo_url or "").strip()
if not photo_url.startswith(("http://", "https://")):
return photo_url or None
content, mime_type = self._fetch_binary(photo_url)
ext = mimetypes.guess_extension(mime_type) or ".jpg"
filename = f"doris-{uuid.uuid4().hex}{ext}"
boundary = f"----DorisBoundary{uuid.uuid4().hex}"
parts = [
f"--{boundary}\r\n".encode("utf-8"),
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode("utf-8"),
f"Content-Type: {mime_type}\r\n\r\n".encode("utf-8"),
content,
f"\r\n--{boundary}--\r\n".encode("utf-8"),
]
data = b"".join(parts)
req = request.Request(
f"{self.base_url}/api/upload",
data=data,
headers={
"Authorization": f"Bearer {self.token}",
"Accept": "application/json",
"Content-Type": f"multipart/form-data; boundary={boundary}",
"User-Agent": "doris-kitchen/1.0",
},
method="POST",
)
with request.urlopen(req, timeout=60) as resp:
payload = json.loads(resp.read().decode("utf-8", "replace"))
return str(payload.get("filename") or "").strip() or None
def list_recipes(self) -> list[dict[str, Any]]:
if not self.configured():
return []
@@ -64,15 +130,61 @@ class KitchenOwlService:
return recipe
return None
def _recipe_update_payload(self, existing: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
uploaded_photo = self._upload_photo_from_url(payload.get("photo")) if payload.get("photo") else existing.get("photo")
return {
"name": payload.get("name") or existing.get("name"),
"description": payload.get("description") or existing.get("description") or "",
"source": payload.get("source") or existing.get("source"),
"visibility": int(payload.get("visibility", existing.get("visibility", 0) or 0)),
"items": payload.get("items") or [
{
"name": item.get("name"),
"description": item.get("description", ""),
"optional": bool(item.get("optional", False)),
}
for item in (existing.get("items") or [])
],
"tags": payload.get("tags") or [
tag.get("name") if isinstance(tag, dict) else str(tag)
for tag in (existing.get("tags") or [])
],
"cook_time": payload.get("cook_time", existing.get("cook_time")),
"prep_time": payload.get("prep_time", existing.get("prep_time")),
"time": payload.get("time", existing.get("time")),
"yields": payload.get("yields", existing.get("yields")),
"photo": uploaded_photo or existing.get("photo"),
"planned": bool(existing.get("planned", False)),
"planned_cooking_dates": existing.get("planned_cooking_dates") or [],
"planned_days": existing.get("planned_days") or [],
}
def update_recipe(self, recipe_id: int | str, payload: dict[str, Any], existing: dict[str, Any] | None = None) -> dict[str, Any]:
if not self.configured():
raise RuntimeError("KitchenOwl credentials are not configured.")
if existing is None:
status, existing = self._request("GET", f"/api/recipe/{recipe_id}")
if status != 200 or not isinstance(existing, dict):
return {"status": "error", "http_status": status, "recipe": existing}
body = self._recipe_update_payload(existing, payload)
status, updated = self._request("POST", f"/api/recipe/{recipe_id}", body=body, timeout=45)
return {"status": "updated" if status == 200 else "error", "http_status": status, "recipe": updated}
def create_recipe(self, payload: dict[str, Any]) -> dict[str, Any]:
if not self.configured():
raise RuntimeError("KitchenOwl credentials are not configured.")
existing = self.find_existing(payload)
if existing:
if payload.get("photo") and not existing.get("photo"):
refreshed = self.update_recipe(existing.get("id"), payload, existing=existing)
if refreshed.get("status") == "updated":
return {"status": "updated", "recipe": refreshed.get("recipe")}
return {"status": "duplicate", "recipe": existing}
status, created = self._request("POST", f"/api/household/{self.household_id}/recipe", body=payload, timeout=45)
create_payload = dict(payload)
if create_payload.get("photo"):
create_payload["photo"] = self._upload_photo_from_url(create_payload["photo"])
status, created = self._request("POST", f"/api/household/{self.household_id}/recipe", body=create_payload, timeout=45)
return {"status": "created" if status == 200 else "error", "http_status": status, "recipe": created}
kitchenowl = KitchenOwlService()

View File

@@ -8,6 +8,8 @@ from html.parser import HTMLParser
from typing import Any
from urllib import error, request
from app.services.tagging import curate_recipe_tags
UNIT_WORDS = {
"teaspoon", "teaspoons", "tsp", "tsp.", "tablespoon", "tablespoons", "tbsp", "tbsp.", "cup", "cups",
@@ -147,10 +149,110 @@ def coerce_text(value: Any) -> str:
return unescape(str(value)).strip()
def slugify_tag(value: str) -> str:
value = value.strip().lower()
value = re.sub(r"[^a-z0-9]+", "-", value)
return value.strip("-")[:48]
def normalize_whitespace(value: str) -> str:
cleaned = re.sub(r"\s+", " ", value or "").strip()
return re.sub(r"(?<=[.!?])(?=[A-Z])", " ", cleaned)
def split_paragraphs(value: str) -> list[str]:
parts: list[str] = []
for chunk in re.split(r"\n\s*\n|\r\n\r\n", value or ""):
cleaned = normalize_whitespace(chunk)
if cleaned:
parts.append(cleaned)
return parts
def extract_instruction_steps(value: Any) -> list[str]:
steps: list[str] = []
def visit(node: Any) -> None:
if node is None:
return
if isinstance(node, str):
for chunk in re.split(r"\n+", node):
cleaned = normalize_whitespace(chunk)
if cleaned:
steps.append(cleaned)
return
if isinstance(node, list):
for item in node:
visit(item)
return
if isinstance(node, dict):
if node.get("@type") == "HowToSection":
name = normalize_whitespace(coerce_text(node.get("name")))
if name:
steps.append(f"{name}:")
visit(node.get("itemListElement"))
return
if node.get("@type") in {"HowToStep", "ListItem"}:
text = normalize_whitespace(coerce_text(node.get("text") or node.get("name") or node.get("item")))
if text:
steps.append(text)
elif node.get("itemListElement") is not None:
visit(node.get("itemListElement"))
return
if node.get("itemListElement") is not None:
visit(node.get("itemListElement"))
return
text = normalize_whitespace(coerce_text(node.get("text") or node.get("name")))
if text:
steps.append(text)
visit(value)
deduped: list[str] = []
seen: set[str] = set()
for step in steps:
key = step.lower()
if key in seen:
continue
seen.add(key)
deduped.append(step)
return deduped
def build_description(recipe_node: dict[str, Any]) -> str:
intro_parts = split_paragraphs(coerce_text(recipe_node.get("description")))
intro = intro_parts[0] if intro_parts else ""
notes = intro_parts[1:] if len(intro_parts) > 1 else []
steps = extract_instruction_steps(recipe_node.get("recipeInstructions"))
parts: list[str] = []
if intro:
parts.append(intro)
if steps:
rendered_steps = []
step_number = 1
for idx, step in enumerate(steps, start=1):
if step.endswith(":"):
rendered_steps.append(step)
else:
rendered_steps.append(f"{step_number}. {step}")
step_number += 1
parts.append("\n".join(rendered_steps))
elif intro_parts[1:]:
parts.append("\n".join(intro_parts[1:]))
if notes:
parts.append("Notes:\n- " + "\n- ".join(notes))
return "\n\n".join(part for part in parts if part).strip()
def extract_image_url(value: Any) -> str | None:
if isinstance(value, str):
cleaned = value.strip()
return cleaned or None
if isinstance(value, list):
for item in value:
found = extract_image_url(item)
if found:
return found
return None
if isinstance(value, dict):
for key in ("url", "contentUrl"):
found = extract_image_url(value.get(key))
if found:
return found
return None
def normalize_keywords(value: Any) -> list[str]:
@@ -212,9 +314,7 @@ def parse_duration_minutes(value: Any) -> int | None:
def normalize_recipe(url: str, recipe_node: dict[str, Any], title_fallback: str | None = None) -> dict[str, Any]:
name = coerce_text(recipe_node.get("name")) or (title_fallback or url)
desc = coerce_text(recipe_node.get("description"))
instructions = coerce_text(recipe_node.get("recipeInstructions"))
description = "\n\n".join(part for part in [desc, instructions] if part).strip()
description = build_description(recipe_node)
items: list[dict[str, Any]] = []
seen_items: set[tuple[str, str]] = set()
for raw in recipe_node.get("recipeIngredient") or []:
@@ -232,13 +332,13 @@ def normalize_recipe(url: str, recipe_node: dict[str, Any], title_fallback: str
tags: list[str] = []
for key in TAG_KEYS:
tags.extend(normalize_keywords(recipe_node.get(key)))
deduped_tags: list[str] = []
seen_tags: set[str] = set()
for tag in tags:
slug = slugify_tag(tag)
if slug and slug not in seen_tags:
seen_tags.add(slug)
deduped_tags.append(slug)
deduped_tags = curate_recipe_tags(
name=name,
description=description,
items=items,
source=url,
existing_tags=tags,
)
payload = {
"name": name[:128],
"description": description[:20000],
@@ -247,6 +347,9 @@ def normalize_recipe(url: str, recipe_node: dict[str, Any], title_fallback: str
"items": items,
"tags": deduped_tags[:20],
}
image_url = extract_image_url(recipe_node.get("image"))
if image_url:
payload["photo"] = image_url
cook_time = parse_duration_minutes(recipe_node.get("cookTime"))
prep_time = parse_duration_minutes(recipe_node.get("prepTime"))
total_time = parse_duration_minutes(recipe_node.get("totalTime"))
@@ -274,4 +377,3 @@ def normalize_recipe_from_url(url: str, timeout: int = 30) -> dict[str, Any]:
if not recipe_node:
raise RuntimeError(f"No schema.org Recipe JSON-LD found for {url}")
return normalize_recipe(url, recipe_node, title)

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
from typing import Any
from urllib import parse, request
@@ -21,6 +22,9 @@ ALLOWED_DOMAINS = (
"spendwithpennies.com",
"delish.com",
"food.com",
"feelgoodfoodie.net",
"simplyrecipes.com",
"themediterraneandish.com",
)
FALLBACK_SEED_URLS = [
"https://www.budgetbytes.com/curry-beef-with-peas/",
@@ -28,13 +32,125 @@ FALLBACK_SEED_URLS = [
"https://www.spendwithpennies.com/ground-beef-stroganoff/",
"https://www.budgetbytes.com/chicken-and-rice-skillet/",
]
QUERY_URL_FALLBACKS = {
"colcannon recipe": [
"https://www.allrecipes.com/recipe/58642/dianes-colcannon/",
"https://www.spendwithpennies.com/colcannon-recipe-cabbage-and-potatoes/",
],
"boxty recipe": [
"https://www.delish.com/cooking/recipe-ideas/a42452906/boxty-recipe/",
],
"coddle recipe": [
"https://www.foodnetwork.com/recipes/food-network-kitchen/irish-coddle-12348168",
],
"corned beef and cabbage recipe": [
"https://www.spendwithpennies.com/corned-beef-and-cabbage/",
"https://www.thepioneerwoman.com/food-cooking/recipes/a11462/corned-beef-and-cabbage/",
],
"shepherds pie recipe": [
"https://natashaskitchen.com/shepherds-pie-recipe/",
],
"kofta recipe ground beef": [
"https://www.allrecipes.com/recipe/106030/kofta-kebabs/",
],
"kafta recipe ground beef": [
"https://feelgoodfoodie.net/recipe/beef-kafta/",
],
"arayes recipe": [
"https://www.recipetineats.com/arayes-lebanese-meat-stuffed-pita/",
"https://www.allrecipes.com/arayes-lebanese-crispy-meat-stuffed-pita-recipe-8635793",
],
"hashweh recipe": [
"https://www.themediterraneandish.com/lebanese-rice-hashweh-recipe/",
],
"beef shawarma recipe": [
"https://www.themediterraneandish.com/grilled-beef-shawarma/",
],
"kofta kebabs recipe": [
"https://www.allrecipes.com/recipe/106030/kofta-kebabs/",
],
"kibbeh recipe": [
"https://feelgoodfoodie.net/recipe/baked-kibbeh/",
],
}
QUERY_STOPWORDS = {
"a", "an", "and", "for", "from", "in", "inspired", "meal", "meals",
"recipe", "recipes", "style", "using", "with",
}
CUE_WORDS = {
"asian", "irish", "italian", "mexican", "thai", "indian", "korean",
"japanese", "greek", "mediterranean", "southern", "cajun", "middle", "eastern",
}
TITLE_STOPWORDS = {
"a", "an", "and", "best", "classic", "easy", "for", "how", "make",
"recipe", "recipes", "style", "the", "to", "traditional", "slow", "cooker", "s",
}
class RecipeSearchService:
def __init__(self) -> None:
self.store = JsonStore()
def _dish_key(self, title: str, payload: dict[str, Any]) -> str:
base = str(title or payload.get("name") or "").lower()
base = re.sub(r"\([^)]*\)", " ", base)
base = re.sub(r"[^a-z0-9\s-]", " ", base)
tokens = [token for token in re.split(r"\s+", base) if token and token not in TITLE_STOPWORDS]
if len(tokens) > 1 and tokens[0].endswith("s"):
tokens = tokens[1:]
canonical_tokens = sorted(dict.fromkeys(tokens))
return " ".join(canonical_tokens[:8]).strip()
def _candidate_queries(self, query: str) -> list[str]:
normalized = re.sub(r"[^a-z0-9\s-]", " ", query.lower())
normalized = re.sub(r"\s+", " ", normalized).strip()
tokens = [token for token in normalized.split() if token]
filtered = [token for token in tokens if token not in QUERY_STOPWORDS]
cues = [token for token in filtered if token in CUE_WORDS]
ingredients = [token for token in filtered if token not in CUE_WORDS]
expansion_queries: list[str] = []
filtered_set = set(filtered)
if {"irish", "cabbage", "potatoes"} <= filtered_set:
expansion_queries.extend([
"colcannon recipe",
"boxty recipe",
"coddle recipe",
"corned beef and cabbage recipe",
"shepherds pie recipe",
])
if {"middle", "eastern", "ground", "beef"} <= filtered_set:
expansion_queries.extend([
"kofta recipe ground beef",
"kafta recipe ground beef",
"middle eastern beef kofta",
"arayes recipe",
"hashweh recipe",
"beef shawarma recipe",
"kofta kebabs recipe",
"kibbeh recipe",
])
variants = [
query.strip(),
" ".join(filtered),
" ".join(cues + ingredients + ["recipe"]).strip(),
" ".join(ingredients + ["recipe"]).strip(),
] + expansion_queries
deduped: list[str] = []
seen: set[str] = set()
for variant in variants:
cleaned = variant.strip()
if not cleaned:
continue
key = cleaned.lower()
if key in seen:
continue
seen.add(key)
deduped.append(cleaned)
return deduped
def _search_page(self, query: str) -> list[dict[str, str]]:
if query in QUERY_URL_FALLBACKS:
return [{"url": url, "title": url, "snippet": ""} for url in QUERY_URL_FALLBACKS[query]]
req = request.Request(
SEARCH_URL + parse.quote(query + ENGLISH_HINT),
headers={
@@ -69,34 +185,48 @@ class RecipeSearchService:
limit = limit or settings.search_result_limit
profile = repo.get_profile()
results: list[dict[str, Any]] = []
for hit in self._search_page(query):
seen_urls: set[str] = set()
seen_dishes: set[str] = set()
for candidate_query in self._candidate_queries(query):
before_count = len(results)
for hit in self._search_page(candidate_query):
if len(results) >= limit:
break
if hit["url"] in seen_urls:
continue
try:
payload = normalize_recipe_from_url(hit["url"], timeout=12)
except Exception:
continue
dish_key = self._dish_key(str(payload.get("name") or hit["title"] or ""), payload)
if dish_key and dish_key in seen_dishes:
continue
score, reasons = score_recipe(query, payload, profile)
suggestion = {
"id": self.store.new_id("suggestion"),
"title": payload.get("name") or hit["title"],
"url": hit["url"],
"source_query": query,
"source_title": hit["title"],
"source_snippet": hit["snippet"],
"payload": payload,
"ingredients": extract_ingredients(payload),
"tags": extract_tags(payload),
"score": score,
"score_reasons": reasons,
"status": "suggested",
"created_at": self.store.now(),
"updated_at": self.store.now(),
"kitchenowl_status": "not-imported",
"ready_for_auto_import": bool(profile.get("auto_import_enabled") and score >= int(profile.get("auto_import_threshold", settings.auto_import_threshold))),
}
saved = repo.upsert_suggestion(suggestion)
results.append(saved)
seen_urls.add(hit["url"])
if dish_key:
seen_dishes.add(dish_key)
if len(results) >= limit:
break
try:
payload = normalize_recipe_from_url(hit["url"], timeout=12)
except Exception:
continue
score, reasons = score_recipe(query, payload, profile)
suggestion = {
"id": self.store.new_id("suggestion"),
"title": payload.get("name") or hit["title"],
"url": hit["url"],
"source_query": query,
"source_title": hit["title"],
"source_snippet": hit["snippet"],
"payload": payload,
"ingredients": extract_ingredients(payload),
"tags": extract_tags(payload),
"score": score,
"score_reasons": reasons,
"status": "suggested",
"created_at": self.store.now(),
"updated_at": self.store.now(),
"kitchenowl_status": "not-imported",
"ready_for_auto_import": bool(profile.get("auto_import_enabled") and score >= int(profile.get("auto_import_threshold", settings.auto_import_threshold))),
}
repo.upsert_suggestion(suggestion)
results.append(suggestion)
return results
def _fallback_seed(self) -> list[dict[str, Any]]:
@@ -126,8 +256,8 @@ class RecipeSearchService:
"kitchenowl_status": "not-imported",
"ready_for_auto_import": False,
}
repo.upsert_suggestion(suggestion)
results.append(suggestion)
saved = repo.upsert_suggestion(suggestion)
results.append(saved)
return results
def seed_queries(self) -> list[dict[str, Any]]:

View File

@@ -58,7 +58,19 @@ class JsonRepository:
current = self.list_suggestions()
for idx, existing in enumerate(current):
if existing.get("url") == item.get("url"):
merged = {**existing, **item}
merged = {**item}
for key in (
"status",
"decision_at",
"kitchenowl_status",
"kitchenowl_result",
):
if existing.get(key) is not None:
merged[key] = existing.get(key)
if existing.get("id"):
merged["id"] = existing["id"]
if existing.get("created_at"):
merged["created_at"] = existing["created_at"]
current[idx] = merged
self.store.save("suggestions", current)
return merged
@@ -83,5 +95,25 @@ class JsonRepository:
items = sorted(self.list_suggestions(), key=lambda item: item.get("updated_at") or item.get("created_at") or "", reverse=True)
return items[:limit]
def list_failed_searches(self) -> list[dict[str, Any]]:
return self.store.load("failed_searches", [])
def log_failed_search(self, item: dict[str, Any]) -> dict[str, Any]:
current = self.list_failed_searches()
current.append(item)
current = sorted(current, key=lambda entry: entry.get("created_at") or "", reverse=True)[:50]
self.store.save("failed_searches", current)
return item
def recent_failed_searches(self, limit: int = 12) -> list[dict[str, Any]]:
return self.list_failed_searches()[:limit]
def update_failed_search(self, failed_search_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
return self.store.update_collection_item(
"failed_searches",
lambda item: str(item.get("id")) == str(failed_search_id),
lambda item: {**item, **updates},
)
repo = JsonRepository()

View File

@@ -0,0 +1,157 @@
from __future__ import annotations
import re
from typing import Any
def slugify_tag(value: str) -> str:
value = value.strip().lower()
value = re.sub(r"[^a-z0-9]+", "-", value)
return value.strip("-")[:48]
CUISINE_TAGS = {
"asian": {"asian"},
"chinese": {"asian", "chinese"},
"indian": {"asian", "indian"},
"irish": {"irish"},
"italian": {"italian"},
"japanese": {"asian", "japanese"},
"korean": {"asian", "korean"},
"lebanese": {"middle-eastern", "lebanese"},
"mediterranean": {"mediterranean"},
"mexican": {"mexican"},
"middle eastern": {"middle-eastern"},
"middle-eastern": {"middle-eastern"},
"thai": {"asian", "thai"},
}
PROTEIN_TAGS = {
"beef": "beef",
"chicken": "chicken",
"lamb": "lamb",
"pork": "pork",
"shrimp": "seafood",
"tofu": "vegetarian",
"turkey": "turkey",
}
COURSE_HINTS = {
"appetizer": "appetizer",
"breakfast": "breakfast",
"dessert": "dessert",
"main course": "main-course",
"main dish": "main-course",
"salad": "salad",
"side dish": "side-dish",
"soup": "soup",
}
METHOD_HINTS = {
"baked": "baked",
"grilled": "grilled",
"noodle": "noodles",
"ramen": "noodles",
"rice": "rice",
"skillet": "skillet",
"slow cooker": "slow-cooker",
"stir fry": "stir-fry",
"stir-fry": "stir-fry",
}
SWEET_HINTS = (
"brown sugar", "cake", "caramel", "chocolate", "cookie", "dessert", "frosting",
"honey", "icing", "maple syrup", "molasses", "muffin", "pie", "pudding", "sweet",
"vanilla", "whipped cream",
)
SAVORY_HINTS = (
"beef", "broth", "butter", "cheese", "chicken", "cilantro", "cumin", "garlic", "ginger",
"ground beef", "lamb", "meat", "noodle", "onion", "paprika", "parsley", "pine nuts",
"rice", "salt", "savory", "soy sauce", "stock", "thyme",
)
def _flatten_texts(items: list[dict[str, Any]] | None) -> str:
flattened: list[str] = []
for item in items or []:
flattened.append(str(item.get("name") or ""))
flattened.append(str(item.get("description") or ""))
return " ".join(flattened)
def curate_recipe_tags(
*,
name: str,
description: str,
items: list[dict[str, Any]] | None,
source: str = "",
existing_tags: list[str] | None = None,
) -> list[str]:
haystack = " ".join(
part for part in [
str(name or ""),
str(description or ""),
_flatten_texts(items),
" ".join(existing_tags or []),
str(source or ""),
]
if part
).lower()
curated: list[str] = []
seen: set[str] = set()
def add(tag: str) -> None:
slug = slugify_tag(tag)
if slug and slug not in seen:
seen.add(slug)
curated.append(slug)
for tag in existing_tags or []:
add(tag)
for key, expanded in CUISINE_TAGS.items():
if key in haystack:
for tag in expanded:
add(tag)
for key, tag in COURSE_HINTS.items():
if key in haystack:
add(tag)
for key, tag in METHOD_HINTS.items():
if key in haystack:
add(tag)
for key, tag in PROTEIN_TAGS.items():
if key in haystack:
add(tag)
if "kibbeh" in haystack:
add("middle-eastern")
add("lebanese")
add("main-course")
if "larb" in haystack or "laab" in haystack:
add("thai")
add("main-course")
if "curry" in haystack:
add("curry")
add("main-course")
if "pasta" in haystack:
add("pasta")
sweet_score = sum(1 for hint in SWEET_HINTS if hint in haystack)
savory_score = sum(1 for hint in SAVORY_HINTS if hint in haystack)
if sweet_score > savory_score and sweet_score > 0:
add("sweet")
elif savory_score > 0:
add("savory")
if "dessert" not in seen and "sweet" not in seen and "main-course" not in seen and "side-dish" not in seen:
add("main-course")
return curated[:20]