Add Doris Kitchen household recipe app
This commit is contained in:
78
home/doris-kitchen/app/services/kitchenowl.py
Normal file
78
home/doris-kitchen/app/services/kitchenowl.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from urllib import error, parse, request
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class KitchenOwlService:
|
||||
def __init__(self) -> None:
|
||||
self.base_url = settings.kitchenowl_base_url.rstrip("/")
|
||||
self.token = settings.kitchenowl_api_token
|
||||
self.household_id = settings.kitchenowl_household_id
|
||||
|
||||
def configured(self) -> bool:
|
||||
return bool(self.base_url and self.token and self.household_id)
|
||||
|
||||
def _request(self, method: str, path: str, *, params: dict[str, Any] | None = None, body: dict[str, Any] | None = None, timeout: int = 30):
|
||||
url = f"{self.base_url}{path}"
|
||||
if params:
|
||||
url += ("&" if "?" in url else "?") + parse.urlencode(params, doseq=True)
|
||||
data = None
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "doris-kitchen/1.0",
|
||||
}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = request.Request(url, data=data, headers=headers, method=method.upper())
|
||||
try:
|
||||
with request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8", "replace")
|
||||
content_type = resp.headers.get("Content-Type", "")
|
||||
parsed = json.loads(raw) if "json" in content_type and raw else raw
|
||||
return resp.status, parsed
|
||||
except error.HTTPError as exc:
|
||||
raw = exc.read().decode("utf-8", "replace")
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except Exception:
|
||||
parsed = raw
|
||||
return exc.code, parsed
|
||||
|
||||
def list_recipes(self) -> list[dict[str, Any]]:
|
||||
if not self.configured():
|
||||
return []
|
||||
status, payload = self._request("GET", f"/api/household/{self.household_id}/recipe")
|
||||
if status == 200 and isinstance(payload, list):
|
||||
return payload
|
||||
return []
|
||||
|
||||
def find_existing(self, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
want_source = str(payload.get("source") or "").strip().lower()
|
||||
want_name = str(payload.get("name") or "").strip().lower()
|
||||
for recipe in self.list_recipes():
|
||||
source = str(recipe.get("source") or "").strip().lower()
|
||||
name = str(recipe.get("name") or "").strip().lower()
|
||||
if want_source and source and source == want_source:
|
||||
return recipe
|
||||
if want_name and name == want_name:
|
||||
return recipe
|
||||
return None
|
||||
|
||||
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:
|
||||
return {"status": "duplicate", "recipe": existing}
|
||||
status, created = self._request("POST", f"/api/household/{self.household_id}/recipe", body=payload, timeout=45)
|
||||
return {"status": "created" if status == 200 else "error", "http_status": status, "recipe": created}
|
||||
|
||||
|
||||
kitchenowl = KitchenOwlService()
|
||||
|
||||
72
home/doris-kitchen/app/services/planner.py
Normal file
72
home/doris-kitchen/app/services/planner.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from typing import Any
|
||||
|
||||
from app.services.repository import repo
|
||||
|
||||
|
||||
def _overlap(left: list[str], right: list[str]) -> list[str]:
|
||||
return sorted(set(left) & set(right))
|
||||
|
||||
|
||||
class PlannerService:
|
||||
def generate(self, *, week_start: str, anchors: list[str], target_meals: int) -> dict[str, Any]:
|
||||
suggestions = [
|
||||
item for item in repo.list_suggestions()
|
||||
if item.get("status") in {"approved", "suggested", "saved"}
|
||||
]
|
||||
chosen: list[dict[str, Any]] = []
|
||||
used_ids: set[str] = set()
|
||||
for item in sorted(suggestions, key=lambda entry: entry.get("score", 0), reverse=True):
|
||||
haystack = " ".join([str(item.get("title") or ""), " ".join(item.get("ingredients") or []), " ".join(item.get("tags") or [])]).lower()
|
||||
if anchors and not any(anchor.strip().lower() in haystack for anchor in anchors if anchor.strip()):
|
||||
continue
|
||||
chosen.append(item)
|
||||
used_ids.add(str(item.get("id")))
|
||||
if len(chosen) >= min(len(anchors), target_meals):
|
||||
break
|
||||
ingredient_counter: Counter[str] = Counter()
|
||||
for item in chosen:
|
||||
ingredient_counter.update(item.get("ingredients") or [])
|
||||
ranked = sorted(
|
||||
(item for item in suggestions if str(item.get("id")) not in used_ids),
|
||||
key=lambda entry: (
|
||||
len(_overlap(list(ingredient_counter), entry.get("ingredients") or [])),
|
||||
entry.get("score", 0),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
for item in ranked:
|
||||
if len(chosen) >= target_meals:
|
||||
break
|
||||
chosen.append(item)
|
||||
used_ids.add(str(item.get("id")))
|
||||
ingredient_counter.update(item.get("ingredients") or [])
|
||||
meals: list[dict[str, Any]] = []
|
||||
shared = [name for name, count in ingredient_counter.items() if count >= 2]
|
||||
for item in chosen[:target_meals]:
|
||||
overlap = _overlap(shared, item.get("ingredients") or [])
|
||||
note = "Reuses: " + ", ".join(overlap[:4]) if overlap else "Adds variety without blowing up the ingredient list."
|
||||
meals.append(
|
||||
{
|
||||
"suggestion_id": item.get("id"),
|
||||
"title": item.get("title"),
|
||||
"url": item.get("url"),
|
||||
"score": item.get("score"),
|
||||
"ingredients": item.get("ingredients") or [],
|
||||
"note": note,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"week_start": week_start,
|
||||
"anchors": anchors,
|
||||
"target_meals": target_meals,
|
||||
"shared_ingredients": shared[:12],
|
||||
"meals": meals,
|
||||
"summary": f"Built {len(meals)} meals with {len(shared[:12])} shared ingredient lanes.",
|
||||
}
|
||||
|
||||
|
||||
planner = PlannerService()
|
||||
|
||||
277
home/doris-kitchen/app/services/recipe_importer.py
Normal file
277
home/doris-kitchen/app/services/recipe_importer.py
Normal file
@@ -0,0 +1,277 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from html import unescape
|
||||
from html.parser import HTMLParser
|
||||
from typing import Any
|
||||
from urllib import error, request
|
||||
|
||||
|
||||
UNIT_WORDS = {
|
||||
"teaspoon", "teaspoons", "tsp", "tsp.", "tablespoon", "tablespoons", "tbsp", "tbsp.", "cup", "cups",
|
||||
"oz", "ounce", "ounces", "lb", "lbs", "pound", "pounds", "gram", "grams", "g", "kg", "ml", "l",
|
||||
"pinch", "dash", "clove", "cloves", "can", "cans", "package", "packages", "pkg", "slice", "slices",
|
||||
"stalk", "stalks",
|
||||
}
|
||||
STOPWORDS = {"fresh", "large", "small", "medium", "optional", "to", "taste", "for", "the", "a", "an"}
|
||||
TAG_KEYS = ("recipeCategory", "recipeCuisine", "keywords")
|
||||
DURATION_RE = re.compile(r"^P(?:T(?:(?P<hours>\d+)H)?(?:(?P<minutes>\d+)M)?(?:(?P<seconds>\d+)S)?)?$")
|
||||
|
||||
|
||||
class ScriptExtractor(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.in_script = False
|
||||
self.script_type = None
|
||||
self._buf: list[str] = []
|
||||
self.scripts: list[tuple[str | None, str]] = []
|
||||
self.title: str | None = None
|
||||
self._in_title = False
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
attrs_d = dict(attrs)
|
||||
if tag == "script":
|
||||
self.in_script = True
|
||||
self.script_type = attrs_d.get("type")
|
||||
self._buf = []
|
||||
elif tag == "title":
|
||||
self._in_title = True
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag == "script" and self.in_script:
|
||||
self.scripts.append((self.script_type, "".join(self._buf)))
|
||||
self.in_script = False
|
||||
self.script_type = None
|
||||
self._buf = []
|
||||
elif tag == "title":
|
||||
self._in_title = False
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self.in_script:
|
||||
self._buf.append(data)
|
||||
elif self._in_title:
|
||||
self.title = (self.title or "") + data
|
||||
|
||||
|
||||
def fetch_url(url: str, timeout: int = 30) -> str:
|
||||
req = request.Request(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0 (Doris Kitchen)",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with request.urlopen(req, timeout=timeout) as resp:
|
||||
charset = resp.headers.get_content_charset() or "utf-8"
|
||||
return resp.read().decode(charset, "replace")
|
||||
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: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;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 URL: {url}")
|
||||
return curl.stdout.decode("utf-8", "replace")
|
||||
|
||||
|
||||
def flatten_graph(node: Any) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
if isinstance(node, list):
|
||||
for item in node:
|
||||
out.extend(flatten_graph(item))
|
||||
elif isinstance(node, dict):
|
||||
if "@graph" in node:
|
||||
out.extend(flatten_graph(node["@graph"]))
|
||||
else:
|
||||
out.append(node)
|
||||
return out
|
||||
|
||||
|
||||
def is_recipe_node(node: dict[str, Any]) -> bool:
|
||||
recipe_type = node.get("@type")
|
||||
if isinstance(recipe_type, list):
|
||||
return "Recipe" in recipe_type
|
||||
return recipe_type == "Recipe"
|
||||
|
||||
|
||||
def choose_recipe_node(nodes: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
recipes = [node for node in nodes if is_recipe_node(node)]
|
||||
if not recipes:
|
||||
return None
|
||||
recipes.sort(key=lambda node: len(node.get("recipeIngredient") or []), reverse=True)
|
||||
return recipes[0]
|
||||
|
||||
|
||||
def parse_json_ld_recipe(html: str) -> tuple[dict[str, Any] | None, str | None]:
|
||||
parser = ScriptExtractor()
|
||||
parser.feed(html)
|
||||
nodes: list[dict[str, Any]] = []
|
||||
for script_type, content in parser.scripts:
|
||||
if script_type and "ld+json" not in script_type:
|
||||
continue
|
||||
content = content.strip()
|
||||
if not content:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(content)
|
||||
except Exception:
|
||||
continue
|
||||
nodes.extend(flatten_graph(data))
|
||||
return choose_recipe_node(nodes), (parser.title.strip() if parser.title else None)
|
||||
|
||||
|
||||
def coerce_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, list):
|
||||
return "\n".join(part for part in (coerce_text(item) for item in value) if part)
|
||||
if isinstance(value, dict):
|
||||
if "text" in value:
|
||||
return coerce_text(value["text"])
|
||||
if "@value" in value:
|
||||
return coerce_text(value["@value"])
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
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_keywords(value: Any) -> list[str]:
|
||||
out: list[str] = []
|
||||
if isinstance(value, str):
|
||||
out.extend(part.strip() for part in re.split(r"[,;|]", value) if part.strip())
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
out.extend(normalize_keywords(item))
|
||||
return out
|
||||
|
||||
|
||||
def split_ingredient(line: str) -> tuple[str, str]:
|
||||
original = " ".join(line.strip().split())
|
||||
if not original:
|
||||
return "", ""
|
||||
tokens = original.replace("–", "-").replace("—", "-").split()
|
||||
desc_tokens: list[str] = []
|
||||
name_tokens = tokens[:]
|
||||
while name_tokens:
|
||||
token = name_tokens[0].strip(",()").lower()
|
||||
if re.fullmatch(r"[\d/.-]+", token) or token in {"x", "×"}:
|
||||
desc_tokens.append(name_tokens.pop(0))
|
||||
continue
|
||||
if token in UNIT_WORDS:
|
||||
desc_tokens.append(name_tokens.pop(0))
|
||||
continue
|
||||
if token == "of" and desc_tokens:
|
||||
desc_tokens.append(name_tokens.pop(0))
|
||||
continue
|
||||
break
|
||||
if not name_tokens:
|
||||
return original[:128], ""
|
||||
while name_tokens and name_tokens[0].strip(",").lower() in STOPWORDS and len(name_tokens) > 1:
|
||||
desc_tokens.append(name_tokens.pop(0))
|
||||
name = " ".join(name_tokens).strip(" ,;")
|
||||
description = " ".join(desc_tokens).strip(" ,;")
|
||||
return name[:128], description[:512]
|
||||
|
||||
|
||||
def parse_duration_minutes(value: Any) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return int(value)
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
match = DURATION_RE.match(text)
|
||||
if match:
|
||||
hours = int(match.group("hours") or 0)
|
||||
minutes = int(match.group("minutes") or 0)
|
||||
seconds = int(match.group("seconds") or 0)
|
||||
total = hours * 60 + minutes + (1 if seconds >= 30 else 0)
|
||||
return total or None
|
||||
number = re.search(r"(\d+)", text)
|
||||
return int(number.group(1)) if number else 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()
|
||||
items: list[dict[str, Any]] = []
|
||||
seen_items: set[tuple[str, str]] = set()
|
||||
for raw in recipe_node.get("recipeIngredient") or []:
|
||||
line = coerce_text(raw)
|
||||
if not line:
|
||||
continue
|
||||
item_name, item_desc = split_ingredient(line)
|
||||
if not item_name:
|
||||
item_name = line[:128]
|
||||
key = (item_name.lower(), item_desc.lower())
|
||||
if key in seen_items:
|
||||
continue
|
||||
seen_items.add(key)
|
||||
items.append({"name": item_name, "description": item_desc, "optional": False})
|
||||
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)
|
||||
payload = {
|
||||
"name": name[:128],
|
||||
"description": description[:20000],
|
||||
"source": url,
|
||||
"visibility": 0,
|
||||
"items": items,
|
||||
"tags": deduped_tags[:20],
|
||||
}
|
||||
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"))
|
||||
if cook_time is not None:
|
||||
payload["cook_time"] = cook_time
|
||||
if prep_time is not None:
|
||||
payload["prep_time"] = prep_time
|
||||
if total_time is not None:
|
||||
payload["time"] = total_time
|
||||
elif cook_time is not None or prep_time is not None:
|
||||
payload["time"] = (cook_time or 0) + (prep_time or 0)
|
||||
yield_value = recipe_node.get("recipeYield")
|
||||
if isinstance(yield_value, list) and yield_value:
|
||||
yield_value = yield_value[0]
|
||||
if yield_value is not None:
|
||||
match = re.search(r"(\d+)", str(yield_value))
|
||||
if match:
|
||||
payload["yields"] = int(match.group(1))
|
||||
return payload
|
||||
|
||||
|
||||
def normalize_recipe_from_url(url: str, timeout: int = 30) -> dict[str, Any]:
|
||||
html = fetch_url(url, timeout=timeout)
|
||||
recipe_node, title = parse_json_ld_recipe(html)
|
||||
if not recipe_node:
|
||||
raise RuntimeError(f"No schema.org Recipe JSON-LD found for {url}")
|
||||
return normalize_recipe(url, recipe_node, title)
|
||||
|
||||
144
home/doris-kitchen/app/services/recipe_search.py
Normal file
144
home/doris-kitchen/app/services/recipe_search.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any
|
||||
from urllib import parse, request
|
||||
|
||||
from app.config import settings
|
||||
from app.services.recipe_importer import normalize_recipe_from_url
|
||||
from app.services.repository import repo
|
||||
from app.services.scoring import extract_ingredients, extract_tags, score_recipe
|
||||
from app.services.store import JsonStore
|
||||
|
||||
|
||||
SEARCH_URL = "https://www.bing.com/search?format=rss&q="
|
||||
ENGLISH_HINT = " site:allrecipes.com OR site:budgetbytes.com OR site:seriouseats.com OR site:recipetineats.com OR site:spendwithpennies.com OR site:delish.com OR site:food.com"
|
||||
ALLOWED_DOMAINS = (
|
||||
"allrecipes.com",
|
||||
"budgetbytes.com",
|
||||
"seriouseats.com",
|
||||
"recipetineats.com",
|
||||
"spendwithpennies.com",
|
||||
"delish.com",
|
||||
"food.com",
|
||||
)
|
||||
FALLBACK_SEED_URLS = [
|
||||
"https://www.budgetbytes.com/curry-beef-with-peas/",
|
||||
"https://www.recipetineats.com/asian-beef-bowls/",
|
||||
"https://www.spendwithpennies.com/ground-beef-stroganoff/",
|
||||
"https://www.budgetbytes.com/chicken-and-rice-skillet/",
|
||||
]
|
||||
|
||||
|
||||
class RecipeSearchService:
|
||||
def __init__(self) -> None:
|
||||
self.store = JsonStore()
|
||||
|
||||
def _search_page(self, query: str) -> list[dict[str, str]]:
|
||||
req = request.Request(
|
||||
SEARCH_URL + parse.quote(query + ENGLISH_HINT),
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0 (Doris Kitchen)",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
)
|
||||
with request.urlopen(req, timeout=30) as resp:
|
||||
rss_text = resp.read().decode("utf-8", "replace")
|
||||
root = ET.fromstring(rss_text)
|
||||
results: list[dict[str, str]] = []
|
||||
for item in root.findall("./channel/item"):
|
||||
href = str(item.findtext("link") or "").strip()
|
||||
title = str(item.findtext("title") or "").strip()
|
||||
snippet = str(item.findtext("description") or "").strip()
|
||||
host = parse.urlparse(href).netloc.lower()
|
||||
if not href.startswith("http"):
|
||||
continue
|
||||
if not any(host.endswith(domain) or host == domain for domain in ALLOWED_DOMAINS):
|
||||
continue
|
||||
results.append({"url": href, "title": title, "snippet": snippet})
|
||||
deduped: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for item in results:
|
||||
if item["url"] in seen:
|
||||
continue
|
||||
seen.add(item["url"])
|
||||
deduped.append(item)
|
||||
return deduped
|
||||
|
||||
def search(self, query: str, limit: int | None = None) -> list[dict[str, Any]]:
|
||||
limit = limit or settings.search_result_limit
|
||||
profile = repo.get_profile()
|
||||
results: list[dict[str, Any]] = []
|
||||
for hit in self._search_page(query):
|
||||
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]]:
|
||||
profile = repo.get_profile()
|
||||
results: list[dict[str, Any]] = []
|
||||
for url in FALLBACK_SEED_URLS:
|
||||
try:
|
||||
payload = normalize_recipe_from_url(url, timeout=12)
|
||||
except Exception:
|
||||
continue
|
||||
score, reasons = score_recipe("seeded household dinner", payload, profile)
|
||||
suggestion = {
|
||||
"id": self.store.new_id("suggestion"),
|
||||
"title": payload.get("name") or url,
|
||||
"url": url,
|
||||
"source_query": "seeded household dinner",
|
||||
"source_title": payload.get("name") or url,
|
||||
"source_snippet": "Curated fallback suggestion for initial household testing.",
|
||||
"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": False,
|
||||
}
|
||||
repo.upsert_suggestion(suggestion)
|
||||
results.append(suggestion)
|
||||
return results
|
||||
|
||||
def seed_queries(self) -> list[dict[str, Any]]:
|
||||
profile = repo.get_profile()
|
||||
queries = profile.get("seed_queries") or []
|
||||
seeded: list[dict[str, Any]] = []
|
||||
for query in queries:
|
||||
seeded.extend(self.search(query, limit=2))
|
||||
if not seeded:
|
||||
seeded = self._fallback_seed()
|
||||
return seeded
|
||||
|
||||
|
||||
recipe_search = RecipeSearchService()
|
||||
87
home/doris-kitchen/app/services/repository.py
Normal file
87
home/doris-kitchen/app/services/repository.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from typing import Any
|
||||
|
||||
from app.services.store import JsonStore
|
||||
|
||||
|
||||
DEFAULT_PROFILE = {
|
||||
"id": "household",
|
||||
"household_name": "Stafford's Kitchen",
|
||||
"preference_mode": "household",
|
||||
"soft_blacklist": [
|
||||
{"name": "lamb", "enabled": True},
|
||||
{"name": "mutton", "enabled": True},
|
||||
{"name": "offal", "enabled": True},
|
||||
{"name": "fish", "enabled": True},
|
||||
{"name": "seafood", "enabled": True},
|
||||
{"name": "very spicy", "enabled": True},
|
||||
{"name": "extra hot", "enabled": True},
|
||||
{"name": "ghost pepper", "enabled": True},
|
||||
],
|
||||
"approved_ingredients": {},
|
||||
"rejected_ingredients": {},
|
||||
"approved_tags": {},
|
||||
"rejected_tags": {},
|
||||
"approved_titles": {},
|
||||
"rejected_titles": {},
|
||||
"auto_import_threshold": 96,
|
||||
"auto_import_enabled": True,
|
||||
"seed_queries": [
|
||||
"budget weeknight dinners with ground beef and potatoes",
|
||||
"mild asian inspired ground beef potatoes",
|
||||
"easy chicken rice dinner",
|
||||
"family pasta bake not spicy",
|
||||
"cheap skillet dinner potatoes",
|
||||
],
|
||||
"notes": "",
|
||||
}
|
||||
|
||||
|
||||
class JsonRepository:
|
||||
backend = "json"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.store = JsonStore()
|
||||
|
||||
def get_profile(self) -> dict[str, Any]:
|
||||
profile = self.store.load("profile", None)
|
||||
if profile:
|
||||
return profile
|
||||
return self.store.save("profile", dict(DEFAULT_PROFILE))
|
||||
|
||||
def save_profile(self, profile: dict[str, Any]) -> dict[str, Any]:
|
||||
return self.store.save("profile", profile)
|
||||
|
||||
def list_suggestions(self) -> list[dict[str, Any]]:
|
||||
return self.store.load("suggestions", [])
|
||||
|
||||
def upsert_suggestion(self, item: dict[str, Any]) -> dict[str, Any]:
|
||||
current = self.list_suggestions()
|
||||
for idx, existing in enumerate(current):
|
||||
if existing.get("url") == item.get("url"):
|
||||
merged = {**existing, **item}
|
||||
current[idx] = merged
|
||||
self.store.save("suggestions", current)
|
||||
return merged
|
||||
current.append(item)
|
||||
self.store.save("suggestions", current)
|
||||
return item
|
||||
|
||||
def update_suggestion(self, suggestion_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
|
||||
return self.store.update_collection_item(
|
||||
"suggestions",
|
||||
lambda item: str(item.get("id")) == str(suggestion_id),
|
||||
lambda item: {**item, **updates},
|
||||
)
|
||||
|
||||
def list_meal_plans(self) -> list[dict[str, Any]]:
|
||||
return self.store.load("meal_plans", [])
|
||||
|
||||
def create_meal_plan(self, item: dict[str, Any]) -> dict[str, Any]:
|
||||
return self.store.append_collection("meal_plans", item)
|
||||
|
||||
def recent_suggestions(self, limit: int = 12) -> list[dict[str, Any]]:
|
||||
items = sorted(self.list_suggestions(), key=lambda item: item.get("updated_at") or item.get("created_at") or "", reverse=True)
|
||||
return items[:limit]
|
||||
|
||||
|
||||
repo = JsonRepository()
|
||||
81
home/doris-kitchen/app/services/scoring.py
Normal file
81
home/doris-kitchen/app/services/scoring.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
def normalize_token(value: str) -> str:
|
||||
value = re.sub(r"[^a-z0-9]+", " ", value.lower())
|
||||
return " ".join(part for part in value.split() if part)
|
||||
|
||||
|
||||
def extract_ingredients(payload: dict[str, Any]) -> list[str]:
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in payload.get("items") or []:
|
||||
name = normalize_token(str(item.get("name") or ""))
|
||||
if not name or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
out.append(name)
|
||||
return out
|
||||
|
||||
|
||||
def extract_tags(payload: dict[str, Any]) -> list[str]:
|
||||
return [normalize_token(str(tag)) for tag in (payload.get("tags") or []) if normalize_token(str(tag))]
|
||||
|
||||
|
||||
def score_recipe(query: str, payload: dict[str, Any], profile: dict[str, Any]) -> tuple[int, list[str]]:
|
||||
score = 50
|
||||
reasons: list[str] = []
|
||||
haystack = " ".join(
|
||||
[
|
||||
normalize_token(str(payload.get("name") or "")),
|
||||
normalize_token(str(payload.get("description") or "")),
|
||||
" ".join(extract_ingredients(payload)),
|
||||
" ".join(extract_tags(payload)),
|
||||
]
|
||||
)
|
||||
query_terms = [term for term in normalize_token(query).split() if len(term) > 2]
|
||||
if query_terms:
|
||||
matched = sum(1 for term in query_terms if term in haystack)
|
||||
score += matched * 6
|
||||
if matched:
|
||||
reasons.append(f"Matches query terms: {matched}")
|
||||
ingredients = extract_ingredients(payload)
|
||||
tags = extract_tags(payload)
|
||||
approved_ingredients = {normalize_token(k): int(v) for k, v in (profile.get("approved_ingredients") or {}).items()}
|
||||
rejected_ingredients = {normalize_token(k): int(v) for k, v in (profile.get("rejected_ingredients") or {}).items()}
|
||||
approved_tags = {normalize_token(k): int(v) for k, v in (profile.get("approved_tags") or {}).items()}
|
||||
rejected_tags = {normalize_token(k): int(v) for k, v in (profile.get("rejected_tags") or {}).items()}
|
||||
soft_blacklist = {normalize_token(item.get("name", "")) for item in (profile.get("soft_blacklist") or []) if item.get("enabled")}
|
||||
for term in sorted(soft_blacklist):
|
||||
if term and term in haystack:
|
||||
score -= 30
|
||||
reasons.append(f"Soft blacklist term: {term}")
|
||||
liked_hits = 0
|
||||
for ingredient in ingredients:
|
||||
liked_hits += approved_ingredients.get(ingredient, 0)
|
||||
score -= min(rejected_ingredients.get(ingredient, 0) * 7, 18)
|
||||
for tag in tags:
|
||||
liked_hits += approved_tags.get(tag, 0)
|
||||
score -= min(rejected_tags.get(tag, 0) * 5, 12)
|
||||
if liked_hits:
|
||||
score += min(liked_hits * 3, 18)
|
||||
reasons.append("Aligned with prior approvals")
|
||||
title = str(payload.get("name") or "")
|
||||
if len(title.split()) <= 10:
|
||||
score += 3
|
||||
reasons.append("Clear short title")
|
||||
total_time = payload.get("time")
|
||||
if isinstance(total_time, int):
|
||||
if total_time <= 45:
|
||||
score += 6
|
||||
reasons.append("Weeknight-friendly time")
|
||||
elif total_time >= 120:
|
||||
score -= 6
|
||||
description = str(payload.get("description") or "")
|
||||
if description.count("\n") >= 3:
|
||||
score += 4
|
||||
reasons.append("Structured instructions")
|
||||
return max(0, min(100, score)), reasons
|
||||
51
home/doris-kitchen/app/services/store.py
Normal file
51
home/doris-kitchen/app/services/store.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class JsonStore:
|
||||
def __init__(self) -> None:
|
||||
self.root = Path(settings.state_dir)
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _path(self, name: str) -> Path:
|
||||
return self.root / f"{name}.json"
|
||||
|
||||
def load(self, name: str, default: Any) -> Any:
|
||||
path = self._path(name)
|
||||
if not path.exists():
|
||||
return default
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
def save(self, name: str, value: Any) -> Any:
|
||||
path = self._path(name)
|
||||
path.write_text(json.dumps(value, indent=2, sort_keys=True), encoding="utf-8")
|
||||
return value
|
||||
|
||||
def append_collection(self, name: str, item: dict[str, Any]) -> dict[str, Any]:
|
||||
items = self.load(name, [])
|
||||
items.append(item)
|
||||
self.save(name, items)
|
||||
return item
|
||||
|
||||
def update_collection_item(self, name: str, predicate, updater):
|
||||
items = self.load(name, [])
|
||||
updated = None
|
||||
for idx, item in enumerate(items):
|
||||
if predicate(item):
|
||||
items[idx] = updater(dict(item))
|
||||
updated = items[idx]
|
||||
break
|
||||
self.save(name, items)
|
||||
return updated
|
||||
|
||||
def now(self) -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def new_id(self, prefix: str) -> str:
|
||||
return f"{prefix}-{uuid4().hex[:12]}"
|
||||
|
||||
Reference in New Issue
Block a user