"""Search endpoint for semantic + keyword search with graceful fallback.""" from __future__ import annotations import logging from typing import Annotated from fastapi import APIRouter, Depends, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from config import get_settings from database import get_session from models import Creator, TechniquePage from schemas import ( SearchResponse, SearchResultItem, SuggestionItem, SuggestionsResponse, ) from search_service import SearchService logger = logging.getLogger("chrysopedia.search.router") router = APIRouter(prefix="/search", tags=["search"]) def _get_search_service() -> SearchService: """Build a SearchService from current settings.""" return SearchService(get_settings()) @router.get("", response_model=SearchResponse) async def search( q: Annotated[str, Query(max_length=500)] = "", scope: Annotated[str, Query()] = "all", limit: Annotated[int, Query(ge=1, le=100)] = 20, db: AsyncSession = Depends(get_session), ) -> SearchResponse: """Semantic search with keyword fallback. - **q**: Search query (max 500 chars). Empty → empty results. - **scope**: ``all`` | ``topics`` | ``creators``. Invalid → defaults to ``all``. - **limit**: Max results (1–100, default 20). """ svc = _get_search_service() result = await svc.search(query=q, scope=scope, limit=limit, db=db) return SearchResponse( items=[SearchResultItem(**item) for item in result["items"]], total=result["total"], query=result["query"], fallback_used=result["fallback_used"], ) @router.get("/suggestions", response_model=SuggestionsResponse) async def suggestions( db: AsyncSession = Depends(get_session), ) -> SuggestionsResponse: """Return popular search suggestions for autocomplete. Combines top technique pages (by view_count), popular topic tags (by technique count), and top creators (by view_count). Returns 8–12 deduplicated items. """ seen: set[str] = set() items: list[SuggestionItem] = [] def _add(text: str, type_: str) -> None: key = text.lower() if key not in seen: seen.add(key) items.append(SuggestionItem(text=text, type=type_)) # Top 4 technique pages by view_count tp_stmt = ( select(TechniquePage.title) .order_by(TechniquePage.view_count.desc(), TechniquePage.title) .limit(4) ) tp_result = await db.execute(tp_stmt) for (title,) in tp_result.all(): _add(title, "technique") # Top 4 topic tags by how many technique pages use them # Unnest the topic_tags ARRAY and count occurrences tag_unnest = ( select( func.unnest(TechniquePage.topic_tags).label("tag"), ) .where(TechniquePage.topic_tags.isnot(None)) .subquery() ) tag_stmt = ( select( tag_unnest.c.tag, func.count().label("cnt"), ) .group_by(tag_unnest.c.tag) .order_by(func.count().desc(), tag_unnest.c.tag) .limit(4) ) tag_result = await db.execute(tag_stmt) for tag, _cnt in tag_result.all(): _add(tag, "topic") # Top 4 creators by view_count cr_stmt = ( select(Creator.name) .where(Creator.hidden.is_(False)) .order_by(Creator.view_count.desc(), Creator.name) .limit(4) ) cr_result = await db.execute(cr_stmt) for (name,) in cr_result.all(): _add(name, "creator") return SuggestionsResponse(suggestions=items)