Expand Doris Kitchen import, failures, and tag curation
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.services.repository import repo
|
||||
from app.services.recipe_search import recipe_search
|
||||
from app.services.store import JsonStore
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["dashboard"])
|
||||
store = JsonStore()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
@@ -16,12 +20,14 @@ async def summary():
|
||||
profile = repo.get_profile()
|
||||
suggestions = repo.list_suggestions()
|
||||
plans = repo.list_meal_plans()
|
||||
failed_searches = repo.recent_failed_searches(limit=6)
|
||||
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),
|
||||
"failed_searches": len(repo.list_failed_searches()),
|
||||
}
|
||||
return {
|
||||
"backend": repo.backend,
|
||||
@@ -29,5 +35,65 @@ async def summary():
|
||||
"counts": counts,
|
||||
"recent_suggestions": repo.recent_suggestions(),
|
||||
"recent_plans": list(reversed(plans[-4:])),
|
||||
"recent_failed_searches": failed_searches,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/failed-searches")
|
||||
async def failed_searches():
|
||||
return {"status": "ok", "items": repo.list_failed_searches()}
|
||||
|
||||
|
||||
@router.get("/failed-searches/{failed_search_id}/results")
|
||||
async def failed_search_results(failed_search_id: str):
|
||||
item = next((entry for entry in repo.list_failed_searches() if str(entry.get("id")) == str(failed_search_id)), None)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Failed search not found.")
|
||||
query = str(item.get("query") or "").strip()
|
||||
if not query:
|
||||
raise HTTPException(status_code=400, detail="Failed search query is empty.")
|
||||
all_results = recipe_search.search(query, limit=10)
|
||||
results = [entry for entry in all_results if str(entry.get("status") or "suggested") == "suggested"]
|
||||
updates = {
|
||||
"last_checked_at": store.now(),
|
||||
"updated_at": store.now(),
|
||||
"result_count": len(results),
|
||||
"sample_titles": [str(entry.get("title") or "") for entry in results[:4]],
|
||||
}
|
||||
if results:
|
||||
updates["status"] = "resolved-with-recipes"
|
||||
updates["resolved_at"] = str(item.get("resolved_at") or store.now())
|
||||
saved = repo.update_failed_search(str(item.get("id")), updates) or {**item, **updates}
|
||||
return {"status": "ok", "item": saved, "results": results}
|
||||
|
||||
|
||||
@router.post("/failed-searches/refresh")
|
||||
async def refresh_failed_searches():
|
||||
items = repo.list_failed_searches()
|
||||
refreshed = []
|
||||
for item in items:
|
||||
query = str(item.get("query") or "").strip()
|
||||
status = str(item.get("status") or "open")
|
||||
if not query or status != "open":
|
||||
refreshed.append(item)
|
||||
continue
|
||||
preview_titles: list[str] = []
|
||||
result_count = 0
|
||||
for candidate_query in recipe_search._candidate_queries(query):
|
||||
hits = recipe_search._search_page(candidate_query)
|
||||
if not hits:
|
||||
continue
|
||||
preview_titles = [str(hit.get("title") or "") for hit in hits[:4] if str(hit.get("title") or "").strip()]
|
||||
result_count = len(hits)
|
||||
break
|
||||
updates = {
|
||||
"last_checked_at": store.now(),
|
||||
"result_count": result_count,
|
||||
"sample_titles": preview_titles,
|
||||
}
|
||||
if result_count > 0:
|
||||
updates["status"] = "resolved-with-recipes"
|
||||
updates["resolved_at"] = store.now()
|
||||
saved = repo.update_failed_search(str(item.get("id")), updates) or {**item, **updates}
|
||||
refreshed.append(saved)
|
||||
return {"status": "ok", "items": refreshed}
|
||||
|
||||
@@ -1,9 +1,38 @@
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
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
|
||||
from app.services.recipe_search import recipe_search
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/search", tags=["search"])
|
||||
store = JsonStore()
|
||||
MIN_HEALTHY_RESULTS = 5
|
||||
|
||||
|
||||
def _build_suggestion(*, query: str, url: str, source_title: str, source_snippet: str, payload: dict) -> dict:
|
||||
profile = repo.get_profile()
|
||||
score, reasons = score_recipe(query, payload, profile)
|
||||
return {
|
||||
"id": store.new_id("suggestion"),
|
||||
"title": payload.get("name") or source_title or url,
|
||||
"url": url,
|
||||
"source_query": query,
|
||||
"source_title": source_title or url,
|
||||
"source_snippet": source_snippet,
|
||||
"payload": payload,
|
||||
"ingredients": extract_ingredients(payload),
|
||||
"tags": extract_tags(payload),
|
||||
"score": score,
|
||||
"score_reasons": reasons,
|
||||
"status": "suggested",
|
||||
"created_at": store.now(),
|
||||
"updated_at": store.now(),
|
||||
"kitchenowl_status": "not-imported",
|
||||
"ready_for_auto_import": bool(profile.get("auto_import_enabled") and score >= int(profile.get("auto_import_threshold", 96))),
|
||||
}
|
||||
|
||||
|
||||
@router.post("")
|
||||
@@ -13,12 +42,60 @@ async def run_search(request: Request):
|
||||
if not query:
|
||||
raise HTTPException(status_code=400, detail="Search query is required.")
|
||||
limit = int(payload.get("limit") or 6)
|
||||
candidate_queries = recipe_search._candidate_queries(query)
|
||||
results = recipe_search.search(query, limit=limit)
|
||||
return {"status": "ok", "query": query, "results": results}
|
||||
failed_search = None
|
||||
if len(results) < MIN_HEALTHY_RESULTS:
|
||||
attention_type = "no-results" if not results else "low-result-count"
|
||||
notes = "No recipe suggestions returned from the configured search lane." if not results else f"Search returned only {len(results)} unique recipes; more coverage needed."
|
||||
existing = next(
|
||||
(
|
||||
item for item in repo.list_failed_searches()
|
||||
if str(item.get("query") or "").strip().lower() == query.lower() and str(item.get("status") or "open").startswith("open")
|
||||
),
|
||||
None,
|
||||
)
|
||||
failed_search = {
|
||||
"id": str((existing or {}).get("id") or store.new_id("failed-search")),
|
||||
"query": query,
|
||||
"candidate_queries": candidate_queries,
|
||||
"created_at": str((existing or {}).get("created_at") or store.now()),
|
||||
"updated_at": store.now(),
|
||||
"status": "open",
|
||||
"attention_type": attention_type,
|
||||
"result_count": len(results),
|
||||
"sample_titles": [str(item.get("title") or "") for item in results[:4]],
|
||||
"notes": notes,
|
||||
}
|
||||
if existing:
|
||||
repo.update_failed_search(str(existing.get("id")), failed_search)
|
||||
else:
|
||||
repo.log_failed_search(failed_search)
|
||||
return {"status": "ok", "query": query, "results": results, "failed_search": failed_search, "needs_attention": bool(failed_search)}
|
||||
|
||||
|
||||
@router.post("/url")
|
||||
async def ingest_url(request: Request):
|
||||
payload = await request.json()
|
||||
url = str(payload.get("url") or "").strip()
|
||||
if not url.startswith("http://") and not url.startswith("https://"):
|
||||
raise HTTPException(status_code=400, detail="Recipe URL must start with http:// or https://")
|
||||
try:
|
||||
normalized = normalize_recipe_from_url(url, timeout=20)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Could not normalize recipe link: {exc}") from exc
|
||||
suggestion = _build_suggestion(
|
||||
query="direct recipe link",
|
||||
url=url,
|
||||
source_title=url,
|
||||
source_snippet="Recipe URL pasted directly into Doris Kitchen.",
|
||||
payload=normalized,
|
||||
)
|
||||
saved = repo.upsert_suggestion(suggestion)
|
||||
return {"status": "ok", "url": url, "suggestion": saved}
|
||||
|
||||
|
||||
@router.post("/seed")
|
||||
async def seed_queue():
|
||||
results = recipe_search.seed_queries()
|
||||
return {"status": "ok", "seeded": results, "count": len(results)}
|
||||
|
||||
|
||||
@@ -49,7 +49,8 @@ async def decide(suggestion_id: str, request: Request):
|
||||
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():
|
||||
already_linked = str(current.get("status") or "") == "approved" and str(current.get("kitchenowl_status") or "") in {"created", "duplicate", "updated"}
|
||||
if import_now and kitchenowl.configured() and not already_linked:
|
||||
kitchenowl_result = kitchenowl.create_recipe(current.get("payload") or {})
|
||||
current_status = "approved"
|
||||
elif decision == "reject":
|
||||
@@ -69,4 +70,3 @@ async def decide(suggestion_id: str, request: Request):
|
||||
},
|
||||
)
|
||||
return {"status": "ok", "suggestion": updated, "profile": profile, "kitchenowl": kitchenowl_result}
|
||||
|
||||
|
||||
@@ -23,3 +23,7 @@ async def search(request: Request):
|
||||
async def planner_page(request: Request):
|
||||
return templates.TemplateResponse("planner.html", {"request": request, "title": "Meal Planner"})
|
||||
|
||||
|
||||
@router.get("/failures", response_class=HTMLResponse)
|
||||
async def failures_page(request: Request):
|
||||
return templates.TemplateResponse("failures.html", {"request": request, "title": "Failed Searches"})
|
||||
|
||||
Reference in New Issue
Block a user