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