diff --git a/home/doris-kitchen/.env.example b/home/doris-kitchen/.env.example new file mode 100644 index 0000000..7540469 --- /dev/null +++ b/home/doris-kitchen/.env.example @@ -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 + diff --git a/home/doris-kitchen/.gitignore b/home/doris-kitchen/.gitignore new file mode 100644 index 0000000..b343330 --- /dev/null +++ b/home/doris-kitchen/.gitignore @@ -0,0 +1,7 @@ +.env +data/ +__pycache__/ +app/__pycache__/ +app/routes/__pycache__/ +app/services/__pycache__/ + diff --git a/home/doris-kitchen/DEPLOYMENT.md b/home/doris-kitchen/DEPLOYMENT.md new file mode 100644 index 0000000..b38607d --- /dev/null +++ b/home/doris-kitchen/DEPLOYMENT.md @@ -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 + diff --git a/home/doris-kitchen/Dockerfile b/home/doris-kitchen/Dockerfile new file mode 100644 index 0000000..0b1fc40 --- /dev/null +++ b/home/doris-kitchen/Dockerfile @@ -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"] + diff --git a/home/doris-kitchen/README.md b/home/doris-kitchen/README.md new file mode 100644 index 0000000..b866b42 --- /dev/null +++ b/home/doris-kitchen/README.md @@ -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 +``` diff --git a/home/doris-kitchen/app/config.py b/home/doris-kitchen/app/config.py new file mode 100644 index 0000000..55dd200 --- /dev/null +++ b/home/doris-kitchen/app/config.py @@ -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() diff --git a/home/doris-kitchen/app/main.py b/home/doris-kitchen/app/main.py new file mode 100644 index 0000000..df1c781 --- /dev/null +++ b/home/doris-kitchen/app/main.py @@ -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) + diff --git a/home/doris-kitchen/app/routes/api_dashboard.py b/home/doris-kitchen/app/routes/api_dashboard.py new file mode 100644 index 0000000..617b598 --- /dev/null +++ b/home/doris-kitchen/app/routes/api_dashboard.py @@ -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:])), + } + diff --git a/home/doris-kitchen/app/routes/api_planner.py b/home/doris-kitchen/app/routes/api_planner.py new file mode 100644 index 0000000..b3791be --- /dev/null +++ b/home/doris-kitchen/app/routes/api_planner.py @@ -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} + diff --git a/home/doris-kitchen/app/routes/api_profile.py b/home/doris-kitchen/app/routes/api_profile.py new file mode 100644 index 0000000..82454c2 --- /dev/null +++ b/home/doris-kitchen/app/routes/api_profile.py @@ -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} + diff --git a/home/doris-kitchen/app/routes/api_search.py b/home/doris-kitchen/app/routes/api_search.py new file mode 100644 index 0000000..2346f4d --- /dev/null +++ b/home/doris-kitchen/app/routes/api_search.py @@ -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)} + diff --git a/home/doris-kitchen/app/routes/api_suggestions.py b/home/doris-kitchen/app/routes/api_suggestions.py new file mode 100644 index 0000000..e822424 --- /dev/null +++ b/home/doris-kitchen/app/routes/api_suggestions.py @@ -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} + diff --git a/home/doris-kitchen/app/routes/ui.py b/home/doris-kitchen/app/routes/ui.py new file mode 100644 index 0000000..e1014d1 --- /dev/null +++ b/home/doris-kitchen/app/routes/ui.py @@ -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"}) + diff --git a/home/doris-kitchen/app/services/kitchenowl.py b/home/doris-kitchen/app/services/kitchenowl.py new file mode 100644 index 0000000..139f332 --- /dev/null +++ b/home/doris-kitchen/app/services/kitchenowl.py @@ -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() + diff --git a/home/doris-kitchen/app/services/planner.py b/home/doris-kitchen/app/services/planner.py new file mode 100644 index 0000000..4a30b31 --- /dev/null +++ b/home/doris-kitchen/app/services/planner.py @@ -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() + diff --git a/home/doris-kitchen/app/services/recipe_importer.py b/home/doris-kitchen/app/services/recipe_importer.py new file mode 100644 index 0000000..e8e2cb5 --- /dev/null +++ b/home/doris-kitchen/app/services/recipe_importer.py @@ -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\d+)H)?(?:(?P\d+)M)?(?:(?P\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) + diff --git a/home/doris-kitchen/app/services/recipe_search.py b/home/doris-kitchen/app/services/recipe_search.py new file mode 100644 index 0000000..a5a6716 --- /dev/null +++ b/home/doris-kitchen/app/services/recipe_search.py @@ -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() diff --git a/home/doris-kitchen/app/services/repository.py b/home/doris-kitchen/app/services/repository.py new file mode 100644 index 0000000..2880148 --- /dev/null +++ b/home/doris-kitchen/app/services/repository.py @@ -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() diff --git a/home/doris-kitchen/app/services/scoring.py b/home/doris-kitchen/app/services/scoring.py new file mode 100644 index 0000000..464a451 --- /dev/null +++ b/home/doris-kitchen/app/services/scoring.py @@ -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 diff --git a/home/doris-kitchen/app/services/store.py b/home/doris-kitchen/app/services/store.py new file mode 100644 index 0000000..c3aaeea --- /dev/null +++ b/home/doris-kitchen/app/services/store.py @@ -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]}" + diff --git a/home/doris-kitchen/app/static/app.css b/home/doris-kitchen/app/static/app.css new file mode 100644 index 0000000..546fd31 --- /dev/null +++ b/home/doris-kitchen/app/static/app.css @@ -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)); + } +} + diff --git a/home/doris-kitchen/app/static/app.js b/home/doris-kitchen/app/static/app.js new file mode 100644 index 0000000..7fad802 --- /dev/null +++ b/home/doris-kitchen/app/static/app.js @@ -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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +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) => '' + escapeHtml(entry) + '').join(''); + const reasons = (item.score_reasons || []).slice(0, 4).map((entry) => '
  • ' + escapeHtml(entry) + '
  • ').join(''); + const kitchenowl = item.kitchenowl_status && item.kitchenowl_status !== 'not-imported' + ? '

    KitchenOwl: ' + escapeHtml(item.kitchenowl_status) + '

    ' + : ''; + return ` +
    +
    +
    +

    Score ${escapeHtml(item.score)}

    +

    ${escapeHtml(item.title)}

    +

    ${escapeHtml(item.source_query || item.source_title || '')}

    +
    +

    ${escapeHtml(item.status || 'suggested')}

    +
    + ${kitchenowl} +
    ${ingredients || 'No ingredient list parsed'}
    +
      ${reasons}
    +
    + + + +
    +
    + `; + }).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) => '').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) => '' + escapeHtml(item) + '').join(''); + target.classList.remove('muted'); + target.innerHTML = ` +
    + +

    ${escapeHtml(plan.summary)}

    +
    ${shared || 'No shared ingredients landed yet.'}
    +
    + ${(plan.meals || []).map((meal, index) => ` +
    +

    Meal ${index + 1}

    +

    ${escapeHtml(meal.title)}

    +

    ${escapeHtml(meal.note)}

    +
    ${(meal.ingredients || []).slice(0, 8).map((item) => '' + escapeHtml(item) + '').join('')}
    +
    + `).join('')} + `; + } catch (error) { + target.textContent = error.message; + } + }); +} + +loadDashboard().catch(() => {}); +wireDashboard(); +wireSearch(); +wirePlanner(); +if (document.getElementById('search-results')) { + loadSuggestionsInto(document.getElementById('search-results')).catch(() => {}); +} diff --git a/home/doris-kitchen/app/templates/base.html b/home/doris-kitchen/app/templates/base.html new file mode 100644 index 0000000..5fda2d6 --- /dev/null +++ b/home/doris-kitchen/app/templates/base.html @@ -0,0 +1,28 @@ + + + + + + {{ title or 'Doris Kitchen' }} + + + + +
    + {% block content %}{% endblock %} +
    + + + + diff --git a/home/doris-kitchen/app/templates/dashboard.html b/home/doris-kitchen/app/templates/dashboard.html new file mode 100644 index 0000000..a2500c5 --- /dev/null +++ b/home/doris-kitchen/app/templates/dashboard.html @@ -0,0 +1,73 @@ +{% extends 'base.html' %} +{% block content %} +
    +
    + +

    Leanne picks. Doris learns.

    +

    Queue up web recipes, teach the household profile with yes/no decisions, and keep KitchenOwl for the final recipes worth keeping.

    +
    + + Search a recipe idea +
    +
    +
    +
    +
    Suggested0
    +
    Approved0
    +
    Saved0
    +
    Rejected0
    +
    Plans0
    +
    +

    Loading state…

    +
    +
    + +
    +
    +
    +
    + +

    Terms to push down

    +
    +
    +
    + + +
    +
    Loading…
    +
    + +
    +
    +
    + +

    Household guidance

    +
    +
    +
    + + + + +
    +
    +
    + +
    +
    +
    + +

    Recent recipe suggestions

    +
    +
    +
    Loading…
    +
    +{% endblock %} + diff --git a/home/doris-kitchen/app/templates/planner.html b/home/doris-kitchen/app/templates/planner.html new file mode 100644 index 0000000..b320a9a --- /dev/null +++ b/home/doris-kitchen/app/templates/planner.html @@ -0,0 +1,41 @@ +{% extends 'base.html' %} +{% block content %} +
    +
    +
    +
    + +

    Build the week around a few anchor meals

    +
    +
    +
    + + + + +
    +
    +
    + +

    Waste-aware planning

    +

    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.

    +
    +
    + +
    +
    +
    + +

    Suggested meal week

    +
    +
    +
    Generate a plan to see the meal lineup.
    +
    +{% endblock %} + diff --git a/home/doris-kitchen/app/templates/search.html b/home/doris-kitchen/app/templates/search.html new file mode 100644 index 0000000..ab6d2cd --- /dev/null +++ b/home/doris-kitchen/app/templates/search.html @@ -0,0 +1,28 @@ +{% extends 'base.html' %} +{% block content %} +
    +
    +
    + +

    Tell Doris what sort of dinner you want

    +
    +
    +
    + + +
    +
    + +
    +
    +
    + +

    Candidate recipes

    +
    +
    +
    Search for something to load recipe cards.
    +
    +{% endblock %} + diff --git a/home/doris-kitchen/bin/check-env.sh b/home/doris-kitchen/bin/check-env.sh new file mode 100644 index 0000000..daf5202 --- /dev/null +++ b/home/doris-kitchen/bin/check-env.sh @@ -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 + diff --git a/home/doris-kitchen/bin/run-local.sh b/home/doris-kitchen/bin/run-local.sh new file mode 100644 index 0000000..40ef91f --- /dev/null +++ b/home/doris-kitchen/bin/run-local.sh @@ -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 + diff --git a/home/doris-kitchen/docker-compose.yaml b/home/doris-kitchen/docker-compose.yaml new file mode 100644 index 0000000..ad7a034 --- /dev/null +++ b/home/doris-kitchen/docker-compose.yaml @@ -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 + diff --git a/home/doris-kitchen/requirements.txt b/home/doris-kitchen/requirements.txt new file mode 100644 index 0000000..aaa69a8 --- /dev/null +++ b/home/doris-kitchen/requirements.txt @@ -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 +