Expand Doris Kitchen import, failures, and tag curation
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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()
|
||||
|
||||
157
home/doris-kitchen/app/services/tagging.py
Normal file
157
home/doris-kitchen/app/services/tagging.py
Normal 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]
|
||||
Reference in New Issue
Block a user