chrysopedia/backend/routers/search.py
jlightner 1254e173d4 test: Added GET /api/v1/search/suggestions endpoint returning popular t…
- "backend/schemas.py"
- "backend/routers/search.py"
- "backend/tests/test_search.py"

GSD-Task: S04/T01
2026-03-31 06:35:37 +00:00

118 lines
3.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 (1100, 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 812 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)