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("") 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) candidate_queries = recipe_search._candidate_queries(query) results = recipe_search.search(query, limit=limit) 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)}