- app/services/recipe/tag_inferrer.py: infer tags from recipe ingredient text - app/db/migrations/022_recipe_generic_flag.sql, 029_inferred_tags.sql: schema migrations - app/api/endpoints/imitate.py: recipe imitation endpoint stub - app/api/endpoints/community.py: hall-of-chaos easter egg endpoint - scripts/pipeline/infer_recipe_tags.py, backfill_keywords.py: pipeline scripts - scripts/pipeline/build_recipe_index.py: extended index builder - Dockerfile: explicit .env removal as defense-in-depth - frontend/src/components/FeedbackButton.vue: feedback UX improvements - frontend/src/style.css: minor style tweaks - app/cloud_session.py: cloud session improvements - tests/api/test_community_endpoints.py: additional test coverage
185 lines
6.8 KiB
Python
185 lines
6.8 KiB
Python
"""Kiwi — /api/v1/imitate/samples endpoint for Avocet Imitate tab.
|
|
|
|
Returns the actual assembled prompt Kiwi sends to its LLM for recipe generation,
|
|
including the full pantry context (expiry-first ordering), dietary constraints
|
|
(from user_settings if present), and the Level 3 format instructions.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, Depends
|
|
|
|
from app.cloud_session import get_session, CloudUser
|
|
from app.db.store import Store
|
|
|
|
router = APIRouter()
|
|
|
|
_LEVEL3_FORMAT = [
|
|
"",
|
|
"Reply using EXACTLY this plain-text format — no markdown, no bold, no extra commentary:",
|
|
"Title: <name of the dish>",
|
|
"Ingredients: <comma-separated list>",
|
|
"Directions:",
|
|
"1. <first step>",
|
|
"2. <second step>",
|
|
"3. <continue for each step>",
|
|
"Notes: <optional tips>",
|
|
]
|
|
|
|
_LEVEL4_FORMAT = [
|
|
"",
|
|
"Reply using EXACTLY this plain-text format — no markdown, no bold:",
|
|
"Title: <name of the dish>",
|
|
"Ingredients: <comma-separated list>",
|
|
"Directions:",
|
|
"1. <first step>",
|
|
"2. <second step>",
|
|
"Notes: <optional tips>",
|
|
]
|
|
|
|
|
|
def _read_user_settings(store: Store) -> dict:
|
|
"""Read all key/value pairs from user_settings table."""
|
|
try:
|
|
rows = store.conn.execute("SELECT key, value FROM user_settings").fetchall()
|
|
return {r["key"]: r["value"] for r in rows}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _build_recipe_prompt(
|
|
pantry_names: list[str],
|
|
expiring_names: list[str],
|
|
constraints: list[str],
|
|
allergies: list[str],
|
|
level: int = 3,
|
|
) -> str:
|
|
"""Assemble the recipe generation prompt matching Kiwi's Level 3/4 format."""
|
|
# Expiring items first, then remaining pantry items (deduped)
|
|
expiring_set = set(expiring_names)
|
|
ordered = list(expiring_names) + [n for n in pantry_names if n not in expiring_set]
|
|
|
|
if not ordered:
|
|
ordered = pantry_names
|
|
|
|
if level == 4:
|
|
lines = [
|
|
"Surprise me with a creative, unexpected recipe.",
|
|
"Only use ingredients that make culinary sense together. "
|
|
"Do not force flavoured/sweetened items (vanilla yoghurt, flavoured syrups, jam) into savoury dishes.",
|
|
f"Ingredients available: {', '.join(ordered)}",
|
|
]
|
|
if constraints:
|
|
lines.append(f"Constraints: {', '.join(constraints)}")
|
|
if allergies:
|
|
lines.append(f"Must NOT contain: {', '.join(allergies)}")
|
|
lines.append("Treat any mystery ingredient as a wildcard — use your imagination.")
|
|
lines += _LEVEL4_FORMAT
|
|
else:
|
|
lines = [
|
|
"You are a creative chef. Generate a recipe using the ingredients below.",
|
|
"IMPORTANT: When you use a pantry item, list it in Ingredients using its exact name "
|
|
"from the pantry list. Do not add adjectives, quantities, or cooking states "
|
|
"(e.g. use 'butter', not 'unsalted butter' or '2 tbsp butter').",
|
|
"IMPORTANT: Only use pantry items that make culinary sense for the dish. "
|
|
"Do NOT force flavoured/sweetened items (vanilla yoghurt, fruit yoghurt, jam, "
|
|
"dessert sauces, flavoured syrups) into savoury dishes.",
|
|
"IMPORTANT: Do not default to the same ingredient repeatedly across dishes. "
|
|
"If a pantry item does not genuinely improve this specific dish, leave it out.",
|
|
"",
|
|
f"Pantry items: {', '.join(ordered)}",
|
|
]
|
|
if expiring_names:
|
|
lines.append(
|
|
f"Priority — use these soon (expiring): {', '.join(expiring_names)}"
|
|
)
|
|
if constraints:
|
|
lines.append(f"Dietary constraints: {', '.join(constraints)}")
|
|
if allergies:
|
|
lines.append(f"IMPORTANT — must NOT contain: {', '.join(allergies)}")
|
|
lines += _LEVEL3_FORMAT
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
@router.get("/samples")
|
|
async def imitate_samples(
|
|
limit: int = 5,
|
|
level: int = 3,
|
|
session: CloudUser = Depends(get_session),
|
|
):
|
|
"""Return assembled recipe generation prompts for Avocet's Imitate tab.
|
|
|
|
Each sample includes:
|
|
system_prompt empty (Kiwi uses no system context)
|
|
input_text full Level 3/4 prompt with pantry items, expiring items,
|
|
dietary constraints, and format instructions
|
|
output_text empty (no prior LLM output stored per-request)
|
|
|
|
level: 3 (structured with element biasing context) or 4 (wildcard creative)
|
|
limit: max number of distinct prompt variants to return (varies by pantry state)
|
|
"""
|
|
limit = max(1, min(limit, 10))
|
|
store = Store(session.db)
|
|
|
|
# Full pantry for context
|
|
all_items = store.list_inventory()
|
|
pantry_names = [r["product_name"] for r in all_items if r.get("product_name")]
|
|
|
|
# Expiring items as priority ingredients
|
|
expiring = store.expiring_soon(days=14)
|
|
expiring_names = [r["product_name"] for r in expiring if r.get("product_name")]
|
|
|
|
# Dietary constraints from user_settings (keys: constraints, allergies)
|
|
settings = _read_user_settings(store)
|
|
import json as _json
|
|
try:
|
|
constraints = _json.loads(settings.get("dietary_constraints", "[]")) or []
|
|
except Exception:
|
|
constraints = []
|
|
try:
|
|
allergies = _json.loads(settings.get("dietary_allergies", "[]")) or []
|
|
except Exception:
|
|
allergies = []
|
|
|
|
if not pantry_names:
|
|
return {"samples": [], "total": 0, "type": f"recipe_level{level}"}
|
|
|
|
# Build prompt variants: one per expiring item as the "anchor" ingredient,
|
|
# plus one general pantry prompt. Cap at limit.
|
|
samples = []
|
|
seen_anchors: set[str] = set()
|
|
|
|
for item in (expiring[:limit - 1] if expiring else []):
|
|
anchor = item.get("product_name", "")
|
|
if not anchor or anchor in seen_anchors:
|
|
continue
|
|
seen_anchors.add(anchor)
|
|
|
|
# Put this item first in the list for the prompt
|
|
ordered_expiring = [anchor] + [n for n in expiring_names if n != anchor]
|
|
prompt = _build_recipe_prompt(pantry_names, ordered_expiring, constraints, allergies, level)
|
|
|
|
samples.append({
|
|
"id": item.get("id", 0),
|
|
"anchor_item": anchor,
|
|
"expiring_count": len(expiring_names),
|
|
"pantry_count": len(pantry_names),
|
|
"system_prompt": "",
|
|
"input_text": prompt,
|
|
"output_text": "",
|
|
})
|
|
|
|
# One general prompt using all expiring as priority
|
|
if len(samples) < limit:
|
|
prompt = _build_recipe_prompt(pantry_names, expiring_names, constraints, allergies, level)
|
|
samples.append({
|
|
"id": 0,
|
|
"anchor_item": "full pantry",
|
|
"expiring_count": len(expiring_names),
|
|
"pantry_count": len(pantry_names),
|
|
"system_prompt": "",
|
|
"input_text": prompt,
|
|
"output_text": "",
|
|
})
|
|
|
|
return {"samples": samples, "total": len(samples), "type": f"recipe_level{level}"}
|