Add Doris Kitchen household recipe app

This commit is contained in:
Fizzlepoof
2026-05-16 20:22:45 +00:00
parent 19f1f8c5e3
commit 9f614ca0e7
30 changed files with 2009 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
TZ=America/Chicago
DORIS_KITCHEN_HOST=0.0.0.0
DORIS_KITCHEN_PORT=8092
DORIS_KITCHEN_BASE_URL=http://localhost:8092
KITCHENOWL_BASE_URL=https://owl.paccoco.com
KITCHENOWL_API_TOKEN=REPLACE_ME
KITCHENOWL_HOUSEHOLD_ID=1
DORIS_KITCHEN_STATE_DIR=/data/state
DORIS_KITCHEN_SEARCH_RESULT_LIMIT=6
DORIS_KITCHEN_AUTO_IMPORT_THRESHOLD=96

7
home/doris-kitchen/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.env
data/
__pycache__/
app/__pycache__/
app/routes/__pycache__/
app/services/__pycache__/

View File

@@ -0,0 +1,59 @@
# Doris Kitchen Deployment Notes
## Intended runtime shape on NOMAD
Doris Kitchen should run as a standalone NOMAD service from **`/opt/doris-kitchen`**.
- **Repo/source path:** `/home/fizzlepoof/repos/truenas-stacks/home/doris-kitchen`
- **Live runtime path:** `/opt/doris-kitchen`
- **Persistent app data:** `/opt/doris-kitchen/data`
- **Published app port:** `8092`
## Networking
Like Schoolhouse, this app should publish a host port on NOMAD and let Pangolin/Newt forward the public hostname to it.
Recommended exposure model:
- host port `8092`
- Pangolin auth enabled
- household use only for John and Leanne
## Pre-deploy checklist
1. Copy repo source into `/opt/doris-kitchen`.
2. Create a real `.env` from `.env.nomad.example`.
3. Fill in:
- `KITCHENOWL_BASE_URL`
- `KITCHENOWL_API_TOKEN`
- `KITCHENOWL_HOUSEHOLD_ID`
4. Ensure `/opt/doris-kitchen/data` exists and is writable by Docker.
5. Create the Pangolin resource after the hostname is chosen.
## Suggested deploy flow
```bash
sudo mkdir -p /opt/doris-kitchen/data
sudo rsync -a --delete --exclude '.env' --exclude 'data/' /home/fizzlepoof/repos/truenas-stacks/home/doris-kitchen/ /opt/doris-kitchen/
cd /opt/doris-kitchen
sudo cp .env.nomad.example .env
# edit .env
sudo docker compose --env-file .env config
sudo docker compose --env-file .env up -d --build
```
## Post-deploy verification
```bash
curl -fsS http://127.0.0.1:8092/api/health
curl -fsS http://127.0.0.1:8092/api/dashboard/summary
```
Then verify:
- dashboard loads
- search returns normalized recipe suggestions
- soft blacklist toggles persist
- `Yes, add to KitchenOwl` creates or links a KitchenOwl recipe
- planner returns a week with ingredient reuse notes

View File

@@ -0,0 +1,23 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY README.md ./
COPY .env.example ./
COPY bin ./bin
EXPOSE 8092
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8092"]

View File

@@ -0,0 +1,88 @@
# Doris Kitchen
Pangolin-protected household recipe queue, search, taste learning, and weekly meal-planning app for John and Leanne.
## Purpose
This app gives the household a better recipe intake flow than KitchenOwl's native importer:
- search the web for recipe ideas in English-first sources
- queue suggestions for approval, rejection, or later review
- learn household taste from those decisions
- manage soft-blacklisted ingredients and avoid terms
- import high-confidence approved recipes into KitchenOwl
- build weekly meal-plan suggestions that try to reuse ingredients and reduce waste
## Runtime shape
- **Repo source:** `home/doris-kitchen`
- **Expected live NOMAD runtime:** `/opt/doris-kitchen`
- **Expected public path:** `chef.paccoco.com` behind Pangolin auth
Do not run production from an agent workspace.
## Current model
This first pass uses a JSON-backed state store under `/data/state`. It keeps:
- the household taste profile
- suggestion queue and decisions
- generated meal plans
KitchenOwl remains the canonical recipe store after approval/import.
## Household defaults
The app seeds the profile with soft avoid terms for:
- lamb
- mutton
- offal
- fish
- seafood
- spicy / overly spicy
These are soft blacklists, not hard bans. Search and suggestion scoring pushes them down or hides them by default, but the household can toggle them on and off later.
## Features
### Suggestion queue
- `Yes, add to KitchenOwl` approves the recipe, records the preference signal, and imports it when the recipe is not already present.
- `Save for later` keeps the suggestion in the queue without teaching a dislike.
- `No thanks` teaches a household dislike signal from the recipe's ingredients and tags.
### Search
Search runs through English-first recipe results, then normalizes recipe payloads into a KitchenOwl-friendly structure before showing them in the queue.
### Weekly planning
The planner starts from anchor meal ideas and recent approved recipes, then suggests a week that reuses overlapping ingredients where possible.
## Local run
```bash
cd /home/fizzlepoof/repos/truenas-stacks/home/doris-kitchen
cp .env.example .env
bin/run-local.sh
```
## Deploy shape
```bash
sudo mkdir -p /opt/doris-kitchen/data
sudo rsync -a --delete --exclude '.env' --exclude 'data/' /home/fizzlepoof/repos/truenas-stacks/home/doris-kitchen/ /opt/doris-kitchen/
cd /opt/doris-kitchen
sudo cp .env.nomad.example .env
# edit .env
sudo docker compose --env-file .env config
sudo docker compose --env-file .env up -d --build
```
## Verification
```bash
curl -fsS http://127.0.0.1:8092/api/health
curl -fsS http://127.0.0.1:8092/api/dashboard/summary
```

View File

@@ -0,0 +1,18 @@
from dataclasses import dataclass
import os
@dataclass
class Settings:
app_host: str = os.getenv("DORIS_KITCHEN_HOST", "0.0.0.0")
app_port: int = int(os.getenv("DORIS_KITCHEN_PORT", "8092"))
app_base_url: str = os.getenv("DORIS_KITCHEN_BASE_URL", "http://localhost:8092")
state_dir: str = os.getenv("DORIS_KITCHEN_STATE_DIR", "/tmp/doris-kitchen-state")
kitchenowl_base_url: str = os.getenv("KITCHENOWL_BASE_URL", "https://owl.paccoco.com")
kitchenowl_api_token: str = os.getenv("KITCHENOWL_API_TOKEN", "")
kitchenowl_household_id: int = int(os.getenv("KITCHENOWL_HOUSEHOLD_ID", "1"))
search_result_limit: int = int(os.getenv("DORIS_KITCHEN_SEARCH_RESULT_LIMIT", "6"))
auto_import_threshold: int = int(os.getenv("DORIS_KITCHEN_AUTO_IMPORT_THRESHOLD", "96"))
settings = Settings()

View File

@@ -0,0 +1,21 @@
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.routes.api_dashboard import router as dashboard_router
from app.routes.api_planner import router as planner_router
from app.routes.api_profile import router as profile_router
from app.routes.api_search import router as search_router
from app.routes.api_suggestions import router as suggestions_router
from app.routes.ui import router as ui_router
app = FastAPI(title="Doris Kitchen")
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(ui_router)
app.include_router(dashboard_router)
app.include_router(profile_router)
app.include_router(search_router)
app.include_router(suggestions_router)
app.include_router(planner_router)

View File

@@ -0,0 +1,33 @@
from fastapi import APIRouter
from app.services.repository import repo
router = APIRouter(prefix="/api", tags=["dashboard"])
@router.get("/health")
async def health():
return {"status": "ok", "backend": repo.backend}
@router.get("/dashboard/summary")
async def summary():
profile = repo.get_profile()
suggestions = repo.list_suggestions()
plans = repo.list_meal_plans()
counts = {
"suggested": sum(1 for item in suggestions if item.get("status") == "suggested"),
"approved": sum(1 for item in suggestions if item.get("status") == "approved"),
"saved": sum(1 for item in suggestions if item.get("status") == "saved"),
"rejected": sum(1 for item in suggestions if item.get("status") == "rejected"),
"plans": len(plans),
}
return {
"backend": repo.backend,
"profile": profile,
"counts": counts,
"recent_suggestions": repo.recent_suggestions(),
"recent_plans": list(reversed(plans[-4:])),
}

View File

@@ -0,0 +1,26 @@
from fastapi import APIRouter, HTTPException, Request
from app.services.planner import planner
from app.services.repository import repo
from app.services.store import JsonStore
router = APIRouter(prefix="/api/planner", tags=["planner"])
store = JsonStore()
@router.post("/generate")
async def generate_plan(request: Request):
payload = await request.json()
week_start = str(payload.get("week_start") or "").strip()
if not week_start:
raise HTTPException(status_code=400, detail="week_start is required.")
anchors = payload.get("anchors") or []
if isinstance(anchors, str):
anchors = [line.strip() for line in anchors.splitlines() if line.strip()]
target_meals = max(3, min(7, int(payload.get("target_meals") or 5)))
plan = planner.generate(week_start=week_start, anchors=list(anchors), target_meals=target_meals)
record = {"id": store.new_id("mealplan"), **plan, "created_at": store.now()}
repo.create_meal_plan(record)
return {"status": "ok", "plan": record}

View File

@@ -0,0 +1,45 @@
from fastapi import APIRouter, HTTPException, Request
from app.services.repository import repo
router = APIRouter(prefix="/api/profile", tags=["profile"])
@router.get("")
async def get_profile():
return {"profile": repo.get_profile(), "backend": repo.backend}
@router.post("/blacklist")
async def update_blacklist(request: Request):
payload = await request.json()
name = str(payload.get("name") or "").strip().lower()
enabled = bool(payload.get("enabled", True))
if not name:
raise HTTPException(status_code=400, detail="Ingredient or term is required.")
profile = repo.get_profile()
items = list(profile.get("soft_blacklist") or [])
existing = next((item for item in items if str(item.get("name") or "").strip().lower() == name), None)
if existing:
existing["enabled"] = enabled
else:
items.append({"name": name, "enabled": enabled})
profile["soft_blacklist"] = sorted(items, key=lambda item: str(item.get("name") or ""))
repo.save_profile(profile)
return {"status": "ok", "profile": profile}
@router.post("/settings")
async def update_settings(request: Request):
payload = await request.json()
profile = repo.get_profile()
if "notes" in payload:
profile["notes"] = str(payload.get("notes") or "")
if "auto_import_threshold" in payload:
profile["auto_import_threshold"] = max(50, min(100, int(payload["auto_import_threshold"])))
if "auto_import_enabled" in payload:
profile["auto_import_enabled"] = bool(payload["auto_import_enabled"])
repo.save_profile(profile)
return {"status": "ok", "profile": profile}

View File

@@ -0,0 +1,24 @@
from fastapi import APIRouter, HTTPException, Request
from app.services.recipe_search import recipe_search
router = APIRouter(prefix="/api/search", tags=["search"])
@router.post("")
async def run_search(request: Request):
payload = await request.json()
query = str(payload.get("query") or "").strip()
if not query:
raise HTTPException(status_code=400, detail="Search query is required.")
limit = int(payload.get("limit") or 6)
results = recipe_search.search(query, limit=limit)
return {"status": "ok", "query": query, "results": results}
@router.post("/seed")
async def seed_queue():
results = recipe_search.seed_queries()
return {"status": "ok", "seeded": results, "count": len(results)}

View File

@@ -0,0 +1,72 @@
from fastapi import APIRouter, HTTPException, Request
from app.services.kitchenowl import kitchenowl
from app.services.repository import repo
from app.services.store import JsonStore
router = APIRouter(prefix="/api/suggestions", tags=["suggestions"])
store = JsonStore()
def _bump(counter: dict, key: str, amount: int = 1) -> None:
counter[key] = int(counter.get(key, 0)) + amount
def _apply_learning(profile: dict, suggestion: dict, approved: bool) -> dict:
ingredients = suggestion.get("ingredients") or []
tags = suggestion.get("tags") or []
title = str(suggestion.get("title") or "").strip().lower()
ingredient_counter_key = "approved_ingredients" if approved else "rejected_ingredients"
tag_counter_key = "approved_tags" if approved else "rejected_tags"
title_counter_key = "approved_titles" if approved else "rejected_titles"
for ingredient in ingredients:
_bump(profile.setdefault(ingredient_counter_key, {}), ingredient)
for tag in tags:
_bump(profile.setdefault(tag_counter_key, {}), tag)
if title:
_bump(profile.setdefault(title_counter_key, {}), title)
return profile
@router.get("")
async def list_suggestions():
items = sorted(repo.list_suggestions(), key=lambda item: item.get("updated_at") or item.get("created_at") or "", reverse=True)
return {"items": items, "backend": repo.backend}
@router.post("/{suggestion_id}/decision")
async def decide(suggestion_id: str, request: Request):
payload = await request.json()
decision = str(payload.get("decision") or "").strip().lower()
current = next((item for item in repo.list_suggestions() if str(item.get("id")) == str(suggestion_id)), None)
if not current:
raise HTTPException(status_code=404, detail="Suggestion not found.")
if decision not in {"approve", "reject", "later"}:
raise HTTPException(status_code=400, detail="Decision must be approve, reject, or later.")
profile = repo.get_profile()
kitchenowl_result = None
if decision == "approve":
profile = _apply_learning(profile, current, approved=True)
import_now = bool(payload.get("import_to_kitchenowl", True))
if import_now and kitchenowl.configured():
kitchenowl_result = kitchenowl.create_recipe(current.get("payload") or {})
current_status = "approved"
elif decision == "reject":
profile = _apply_learning(profile, current, approved=False)
current_status = "rejected"
else:
current_status = "saved"
repo.save_profile(profile)
updated = repo.update_suggestion(
suggestion_id,
{
"status": current_status,
"updated_at": store.now(),
"decision_at": store.now(),
"kitchenowl_status": (kitchenowl_result or {}).get("status") if kitchenowl_result else current.get("kitchenowl_status", "not-imported"),
"kitchenowl_result": kitchenowl_result,
},
)
return {"status": "ok", "suggestion": updated, "profile": profile, "kitchenowl": kitchenowl_result}

View File

@@ -0,0 +1,25 @@
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
router = APIRouter()
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
@router.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
return templates.TemplateResponse("dashboard.html", {"request": request, "title": "Doris Kitchen"})
@router.get("/search", response_class=HTMLResponse)
async def search(request: Request):
return templates.TemplateResponse("search.html", {"request": request, "title": "Recipe Search"})
@router.get("/planner", response_class=HTMLResponse)
async def planner_page(request: Request):
return templates.TemplateResponse("planner.html", {"request": request, "title": "Meal Planner"})

View 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()

View 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()

View 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)

View 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()

View 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()

View 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

View 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]}"

View File

@@ -0,0 +1,263 @@
:root {
--ink: #2c1810;
--paper: #f8f0df;
--paper-2: #fff8eb;
--accent: #c7682d;
--accent-deep: #8b3d14;
--olive: #6c7a3d;
--rose: #a74735;
--line: rgba(92, 52, 28, 0.2);
--muted: #755f53;
--shadow: 0 18px 40px rgba(73, 34, 11, 0.13);
}
* { box-sizing: border-box; }
body {
margin: 0;
color: var(--ink);
font-family: Georgia, "Palatino Linotype", serif;
background:
radial-gradient(circle at top left, rgba(199, 104, 45, 0.14), transparent 24rem),
radial-gradient(circle at 85% 10%, rgba(108, 122, 61, 0.14), transparent 18rem),
linear-gradient(180deg, #f4ead7, #efe0c8 55%, #ead7bf);
}
.site-header,
.page-shell {
max-width: 1180px;
margin: 0 auto;
padding: 24px;
}
.site-header {
display: grid;
gap: 18px;
}
.eyebrow,
.section-label {
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 0.75rem;
color: var(--accent-deep);
margin: 0 0 8px;
}
h1, h2, h3, p {
margin-top: 0;
}
.lede,
.muted {
color: var(--muted);
}
nav {
display: flex;
gap: 14px;
flex-wrap: wrap;
}
nav a,
.button-link {
color: var(--accent-deep);
text-decoration: none;
font-weight: 700;
}
.page-shell {
display: grid;
gap: 22px;
padding-bottom: 44px;
}
.hero-grid,
.split-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 22px;
}
.card {
background: rgba(255, 248, 235, 0.92);
border: 1px solid var(--line);
border-radius: 24px;
padding: 24px;
box-shadow: var(--shadow);
}
.spotlight {
background:
linear-gradient(145deg, rgba(199, 104, 45, 0.12), rgba(255, 248, 235, 0.88)),
rgba(255, 248, 235, 0.92);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.stats-grid div {
background: var(--paper);
border: 1px solid var(--line);
border-radius: 16px;
padding: 14px;
display: grid;
gap: 8px;
}
.stats-grid span {
color: var(--muted);
font-size: 0.85rem;
}
.stats-grid strong {
font-size: 1.6rem;
}
.section-heading,
.suggestion-top,
.action-row,
.checkbox-row,
.inline-form {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.stack-form {
display: grid;
gap: 14px;
}
label {
display: grid;
gap: 8px;
color: var(--muted);
}
input,
textarea,
button {
font: inherit;
border-radius: 14px;
border: 1px solid var(--line);
padding: 12px 14px;
}
input,
textarea {
background: rgba(255, 255, 255, 0.7);
color: var(--ink);
}
button,
.button-link {
background: var(--accent);
color: #fff7ef;
border: none;
padding: 12px 16px;
border-radius: 999px;
cursor: pointer;
}
.button-link {
display: inline-flex;
align-items: center;
}
button.ghost {
background: rgba(255, 255, 255, 0.7);
color: var(--accent-deep);
border: 1px solid var(--line);
}
button.danger {
color: var(--rose);
}
.chip-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip-list span,
.chip-toggle,
.score-pill,
.status-pill,
.pill {
border-radius: 999px;
padding: 6px 10px;
font-size: 0.82rem;
}
.chip-list span,
.chip-toggle {
background: var(--paper);
border: 1px solid var(--line);
}
.chip-toggle.active,
.score-pill {
background: rgba(199, 104, 45, 0.12);
color: var(--accent-deep);
}
.status-pill {
background: rgba(108, 122, 61, 0.12);
color: var(--olive);
}
.pill-good {
background: rgba(108, 122, 61, 0.14);
color: var(--olive);
display: inline-block;
}
.suggestion-grid,
.plan-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 18px;
}
.suggestion-card,
.plan-card,
.plan-summary {
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255, 255, 255, 0.55);
padding: 18px;
}
.suggestion-card h3,
.plan-card h3 {
margin-bottom: 8px;
}
.suggestion-card a,
.plan-card a {
color: var(--ink);
}
.reason-list {
margin: 12px 0;
padding-left: 18px;
color: var(--muted);
}
@media (max-width: 640px) {
.site-header,
.page-shell {
padding: 18px;
}
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}

View File

@@ -0,0 +1,265 @@
async function fetchJson(url, options) {
const res = await fetch(url, options);
const payload = await res.json();
if (!res.ok) throw new Error(payload.detail || 'Request failed for ' + url);
return payload;
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderSuggestionCards(target, items) {
if (!target) return;
if (!items.length) {
target.textContent = 'Nothing loaded yet.';
target.classList.add('muted');
return;
}
target.classList.remove('muted');
target.innerHTML = items.map((item) => {
const ingredients = (item.ingredients || []).slice(0, 8).map((entry) => '<span>' + escapeHtml(entry) + '</span>').join('');
const reasons = (item.score_reasons || []).slice(0, 4).map((entry) => '<li>' + escapeHtml(entry) + '</li>').join('');
const kitchenowl = item.kitchenowl_status && item.kitchenowl_status !== 'not-imported'
? '<p class="pill pill-good">KitchenOwl: ' + escapeHtml(item.kitchenowl_status) + '</p>'
: '';
return `
<article class="suggestion-card" data-suggestion-id="${escapeHtml(item.id)}">
<div class="suggestion-top">
<div>
<p class="score-pill">Score ${escapeHtml(item.score)}</p>
<h3><a href="${escapeHtml(item.url)}" target="_blank" rel="noreferrer">${escapeHtml(item.title)}</a></h3>
<p class="muted">${escapeHtml(item.source_query || item.source_title || '')}</p>
</div>
<p class="status-pill">${escapeHtml(item.status || 'suggested')}</p>
</div>
${kitchenowl}
<div class="chip-list">${ingredients || '<span>No ingredient list parsed</span>'}</div>
<ul class="reason-list">${reasons}</ul>
<div class="action-row">
<button type="button" data-decision="approve">Yes, add to KitchenOwl</button>
<button type="button" class="ghost" data-decision="later">Save for later</button>
<button type="button" class="ghost danger" data-decision="reject">No thanks</button>
</div>
</article>
`;
}).join('');
}
async function loadDashboard() {
const data = await fetchJson('/api/dashboard/summary');
const counts = data.counts || {};
const profile = data.profile || {};
const setText = (id, value) => {
const el = document.getElementById(id);
if (el) el.textContent = value;
};
setText('count-suggested', counts.suggested || 0);
setText('count-approved', counts.approved || 0);
setText('count-saved', counts.saved || 0);
setText('count-rejected', counts.rejected || 0);
setText('count-plans', counts.plans || 0);
setText('backend-note', 'Storage backend: ' + data.backend);
const blacklist = document.getElementById('blacklist-items');
if (blacklist) {
const items = profile.soft_blacklist || [];
if (!items.length) {
blacklist.textContent = 'No soft blacklist items yet.';
} else {
blacklist.classList.remove('muted');
blacklist.innerHTML = items.map((item) => '<button class="chip-toggle ' + (item.enabled ? 'active' : '') + '" type="button" data-blacklist-name="' + escapeHtml(item.name) + '" data-blacklist-enabled="' + (item.enabled ? '1' : '0') + '">' + escapeHtml(item.name) + '</button>').join('');
}
}
const notes = document.getElementById('profile-notes');
if (notes) notes.value = profile.notes || '';
const threshold = document.getElementById('auto-import-threshold');
if (threshold) threshold.value = profile.auto_import_threshold || 96;
const enabled = document.getElementById('auto-import-enabled');
if (enabled) enabled.checked = Boolean(profile.auto_import_enabled);
renderSuggestionCards(document.getElementById('suggestion-list'), data.recent_suggestions || []);
}
async function handleDecision(event) {
const button = event.target.closest('button[data-decision]');
if (!button) return;
const card = button.closest('[data-suggestion-id]');
const suggestionId = card && card.dataset ? card.dataset.suggestionId : '';
const decision = button.dataset.decision;
if (!suggestionId || !decision) return;
button.disabled = true;
try {
await fetchJson('/api/suggestions/' + encodeURIComponent(suggestionId) + '/decision', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({decision, import_to_kitchenowl: decision === 'approve'}),
});
await loadDashboard();
if (document.getElementById('search-results')) {
await loadSuggestionsInto(document.getElementById('search-results'));
}
} catch (error) {
alert(error.message);
} finally {
button.disabled = false;
}
}
async function loadSuggestionsInto(target) {
const data = await fetchJson('/api/suggestions');
renderSuggestionCards(target, data.items || []);
}
function wireDashboard() {
document.addEventListener('click', async (event) => {
if (event.target.matches('button[data-decision]')) {
await handleDecision(event);
return;
}
if (event.target.matches('button[data-blacklist-name]')) {
const name = event.target.dataset.blacklistName;
const enabled = event.target.dataset.blacklistEnabled !== '1';
await fetchJson('/api/profile/blacklist', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, enabled}),
});
await loadDashboard();
}
});
const seedButton = document.getElementById('seed-button');
if (seedButton) {
seedButton.addEventListener('click', async () => {
seedButton.disabled = true;
seedButton.textContent = 'Seeding…';
try {
await fetchJson('/api/search/seed', {method: 'POST'});
await loadDashboard();
} catch (error) {
alert(error.message);
} finally {
seedButton.disabled = false;
seedButton.textContent = 'Seed suggestion queue';
}
});
}
const blacklistForm = document.getElementById('blacklist-form');
if (blacklistForm) {
blacklistForm.addEventListener('submit', async (event) => {
event.preventDefault();
const input = document.getElementById('blacklist-name');
if (!input.value.trim()) return;
await fetchJson('/api/profile/blacklist', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: input.value.trim(), enabled: true}),
});
input.value = '';
await loadDashboard();
});
}
const settingsForm = document.getElementById('settings-form');
if (settingsForm) {
settingsForm.addEventListener('submit', async (event) => {
event.preventDefault();
await fetchJson('/api/profile/settings', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
notes: document.getElementById('profile-notes').value,
auto_import_threshold: Number(document.getElementById('auto-import-threshold').value || 96),
auto_import_enabled: document.getElementById('auto-import-enabled').checked,
}),
});
await loadDashboard();
});
}
}
function wireSearch() {
const form = document.getElementById('search-form');
if (!form) return;
form.addEventListener('submit', async (event) => {
event.preventDefault();
const query = document.getElementById('search-query').value.trim();
if (!query) return;
const target = document.getElementById('search-results');
target.textContent = 'Searching and normalizing recipes…';
target.classList.add('muted');
try {
const data = await fetchJson('/api/search', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({query, limit: 6}),
});
renderSuggestionCards(target, data.results || []);
} catch (error) {
target.textContent = error.message;
}
});
}
function wirePlanner() {
const form = document.getElementById('planner-form');
if (!form) return;
const weekStart = document.getElementById('week-start');
if (weekStart && !weekStart.value) {
weekStart.value = new Date().toISOString().slice(0, 10);
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
const anchors = document.getElementById('planner-anchors').value;
const target = document.getElementById('plan-output');
target.textContent = 'Building the week…';
target.classList.add('muted');
try {
const data = await fetchJson('/api/planner/generate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
week_start: document.getElementById('week-start').value,
target_meals: Number(document.getElementById('target-meals').value || 5),
anchors,
}),
});
const plan = data.plan;
const shared = (plan.shared_ingredients || []).map((item) => '<span>' + escapeHtml(item) + '</span>').join('');
target.classList.remove('muted');
target.innerHTML = `
<article class="plan-summary">
<p class="section-label">Week of ${escapeHtml(plan.week_start)}</p>
<h3>${escapeHtml(plan.summary)}</h3>
<div class="chip-list">${shared || '<span>No shared ingredients landed yet.</span>'}</div>
</article>
${(plan.meals || []).map((meal, index) => `
<article class="plan-card">
<p class="score-pill">Meal ${index + 1}</p>
<h3><a href="${escapeHtml(meal.url)}" target="_blank" rel="noreferrer">${escapeHtml(meal.title)}</a></h3>
<p class="muted">${escapeHtml(meal.note)}</p>
<div class="chip-list">${(meal.ingredients || []).slice(0, 8).map((item) => '<span>' + escapeHtml(item) + '</span>').join('')}</div>
</article>
`).join('')}
`;
} catch (error) {
target.textContent = error.message;
}
});
}
loadDashboard().catch(() => {});
wireDashboard();
wireSearch();
wirePlanner();
if (document.getElementById('search-results')) {
loadSuggestionsInto(document.getElementById('search-results')).catch(() => {});
}

View File

@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title or 'Doris Kitchen' }}</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<header class="site-header">
<div>
<p class="eyebrow">Doris Kitchen</p>
<h1>Household Recipe Queue</h1>
<p class="lede">Pick recipes, bin bad ones, learn the household taste, and build a cheaper meal week without wasting half a packet of mince. Ha ha.</p>
</div>
<nav>
<a href="/">Queue</a>
<a href="/search">Search</a>
<a href="/planner">Planner</a>
</nav>
</header>
<main class="page-shell">
{% block content %}{% endblock %}
</main>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,73 @@
{% extends 'base.html' %}
{% block content %}
<section class="hero-grid">
<article class="card spotlight">
<p class="section-label">Household taste</p>
<h2>Leanne picks. Doris learns.</h2>
<p class="muted">Queue up web recipes, teach the household profile with yes/no decisions, and keep KitchenOwl for the final recipes worth keeping.</p>
<div class="action-row">
<button type="button" id="seed-button">Seed suggestion queue</button>
<a class="button-link" href="/search">Search a recipe idea</a>
</div>
</article>
<article class="card stats-card">
<div class="stats-grid">
<div><span>Suggested</span><strong id="count-suggested">0</strong></div>
<div><span>Approved</span><strong id="count-approved">0</strong></div>
<div><span>Saved</span><strong id="count-saved">0</strong></div>
<div><span>Rejected</span><strong id="count-rejected">0</strong></div>
<div><span>Plans</span><strong id="count-plans">0</strong></div>
</div>
<p class="muted" id="backend-note">Loading state…</p>
</article>
</section>
<section class="split-grid">
<article class="card">
<div class="section-heading">
<div>
<p class="section-label">Soft blacklist</p>
<h2>Terms to push down</h2>
</div>
</div>
<form id="blacklist-form" class="inline-form">
<input type="text" id="blacklist-name" placeholder="Add ingredient or term">
<button type="submit">Add / enable</button>
</form>
<div id="blacklist-items" class="chip-list muted">Loading…</div>
</article>
<article class="card">
<div class="section-heading">
<div>
<p class="section-label">Preference notes</p>
<h2>Household guidance</h2>
</div>
</div>
<form id="settings-form" class="stack-form">
<label>Notes
<textarea id="profile-notes" rows="4" placeholder="Budget, texture issues, favourite cuisines, or anything else useful."></textarea>
</label>
<label>Auto-import threshold
<input type="number" id="auto-import-threshold" min="50" max="100">
</label>
<label class="checkbox-row">
<input type="checkbox" id="auto-import-enabled">
<span>Allow auto-import when Doris is extremely confident</span>
</label>
<button type="submit">Save settings</button>
</form>
</article>
</section>
<section class="card">
<div class="section-heading">
<div>
<p class="section-label">Queue</p>
<h2>Recent recipe suggestions</h2>
</div>
</div>
<div id="suggestion-list" class="suggestion-grid muted">Loading…</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends 'base.html' %}
{% block content %}
<section class="hero-grid">
<article class="card">
<div class="section-heading">
<div>
<p class="section-label">Meal planner</p>
<h2>Build the week around a few anchor meals</h2>
</div>
</div>
<form id="planner-form" class="stack-form">
<label>Week start
<input type="date" id="week-start">
</label>
<label>How many dinners?
<input type="number" id="target-meals" min="3" max="7" value="5">
</label>
<label>Anchor ideas
<textarea id="planner-anchors" rows="5" placeholder="Ground beef potato skillet&#10;Chicken rice bowls&#10;Pasta bake"></textarea>
</label>
<button type="submit">Build the week</button>
</form>
</article>
<article class="card">
<p class="section-label">What this does</p>
<h2>Waste-aware planning</h2>
<p class="muted">The planner looks at approved and queued meals, then tries to pair dishes that reuse overlapping ingredients. That means less waste, less random spending, and fewer half-dead veg rattling in the fridge drawer.</p>
</article>
</section>
<section class="card">
<div class="section-heading">
<div>
<p class="section-label">Plan output</p>
<h2>Suggested meal week</h2>
</div>
</div>
<div id="plan-output" class="plan-grid muted">Generate a plan to see the meal lineup.</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% block content %}
<section class="card search-card">
<div class="section-heading">
<div>
<p class="section-label">Recipe search</p>
<h2>Tell Doris what sort of dinner you want</h2>
</div>
</div>
<form id="search-form" class="stack-form">
<label>Search request
<textarea id="search-query" rows="3" placeholder="Asian inspired using ground beef with potatoes"></textarea>
</label>
<button type="submit">Find recipe suggestions</button>
</form>
</section>
<section class="card">
<div class="section-heading">
<div>
<p class="section-label">Results</p>
<h2>Candidate recipes</h2>
</div>
</div>
<div id="search-results" class="suggestion-grid muted">Search for something to load recipe cards.</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
have() { command -v "$1" >/dev/null 2>&1; }
printf 'Doris Kitchen environment check\n'
printf '==============================\n'
for cmd in python3 docker curl; do
if have "$cmd"; then
printf '%-10s %s\n' "$cmd" "$(command -v "$cmd")"
else
printf '%-10s %s\n' "$cmd" "MISSING"
fi
done
python3 - <<'PY'
import importlib.util
mods = ['fastapi', 'uvicorn', 'jinja2']
for mod in mods:
print(f'{mod:10} ', 'installed' if importlib.util.find_spec(mod) else 'MISSING')
PY

View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
export DORIS_KITCHEN_STATE_DIR="${DORIS_KITCHEN_STATE_DIR:-/tmp/doris-kitchen-state}"
uvicorn app.main:app --host 0.0.0.0 --port "${DORIS_KITCHEN_PORT:-8092}" --reload

View File

@@ -0,0 +1,30 @@
services:
doris-kitchen:
container_name: doris-kitchen
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "8092:8092"
env_file:
- .env
environment:
TZ: ${TZ}
DORIS_KITCHEN_HOST: 0.0.0.0
DORIS_KITCHEN_PORT: 8092
networks:
- doris-kitchen-net
volumes:
- /opt/doris-kitchen/data:/data
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8092/api/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
networks:
doris-kitchen-net:
driver: bridge

View File

@@ -0,0 +1,6 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
jinja2==3.1.4
python-multipart==0.0.9
httpx==0.27.2