feat: Added sort query parameter (relevance/newest/oldest/alpha/creator…

- "backend/routers/search.py"
- "backend/routers/topics.py"
- "backend/routers/techniques.py"
- "backend/search_service.py"

GSD-Task: S02/T01
This commit is contained in:
jlightner 2026-04-01 06:27:56 +00:00
parent c1cdba14f2
commit 250d7315af
4 changed files with 80 additions and 6 deletions

View file

@ -34,6 +34,7 @@ def _get_search_service() -> SearchService:
async def search(
q: Annotated[str, Query(max_length=500)] = "",
scope: Annotated[str, Query()] = "all",
sort: Annotated[str, Query()] = "relevance",
limit: Annotated[int, Query(ge=1, le=100)] = 20,
db: AsyncSession = Depends(get_session),
) -> SearchResponse:
@ -44,7 +45,7 @@ async def search(
- **limit**: Max results (1100, default 20).
"""
svc = _get_search_service()
result = await svc.search(query=q, scope=scope, limit=limit, db=db)
result = await svc.search(query=q, scope=scope, sort=sort, limit=limit, db=db)
return SearchResponse(
items=[SearchResultItem(**item) for item in result["items"]],
partial_matches=[SearchResultItem(**item) for item in result.get("partial_matches", [])],

View file

@ -157,7 +157,17 @@ async def list_techniques(
stmt = stmt.options(selectinload(TechniquePage.creator))
if sort == "random":
stmt = stmt.order_by(func.random())
elif sort == "oldest":
stmt = stmt.order_by(TechniquePage.created_at.asc())
elif sort == "alpha":
stmt = stmt.order_by(TechniquePage.title.asc())
elif sort == "creator":
# Need a join for creator name ordering; avoid duplicate join if creator_slug filter already joined
if not creator_slug:
stmt = stmt.join(Creator, TechniquePage.creator_id == Creator.id, isouter=True)
stmt = stmt.order_by(Creator.name.asc(), TechniquePage.title.asc())
else:
# Default: "recent" — newest first
stmt = stmt.order_by(TechniquePage.created_at.desc())
stmt = stmt.offset(offset).limit(limit)
result = await db.execute(stmt)

View file

@ -104,6 +104,7 @@ async def list_topics(
async def get_subtopic_techniques(
category_slug: str,
subtopic_slug: str,
sort: Annotated[str, Query()] = "alpha",
offset: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=100)] = 50,
db: AsyncSession = Depends(get_session),
@ -132,10 +133,21 @@ async def get_subtopic_techniques(
stmt = (
stmt.options(selectinload(TechniquePage.creator))
.order_by(TechniquePage.title)
.offset(offset)
.limit(limit)
)
# Apply sort ordering
if sort == "newest":
stmt = stmt.order_by(TechniquePage.created_at.desc())
elif sort == "oldest":
stmt = stmt.order_by(TechniquePage.created_at.asc())
elif sort == "creator":
stmt = stmt.join(Creator, TechniquePage.creator_id == Creator.id, isouter=True)
stmt = stmt.order_by(Creator.name.asc(), TechniquePage.title.asc())
else:
# Default: "alpha" — alphabetical by title
stmt = stmt.order_by(TechniquePage.title.asc())
stmt = stmt.offset(offset).limit(limit)
result = await db.execute(stmt)
pages = result.scalars().all()
@ -158,6 +170,7 @@ async def get_subtopic_techniques(
@router.get("/{category_slug}", response_model=PaginatedResponse)
async def get_topic_techniques(
category_slug: str,
sort: Annotated[str, Query()] = "alpha",
offset: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=100)] = 50,
db: AsyncSession = Depends(get_session),
@ -179,7 +192,21 @@ async def get_topic_techniques(
count_result = await db.execute(count_stmt)
total = count_result.scalar() or 0
stmt = stmt.options(selectinload(TechniquePage.creator)).order_by(TechniquePage.title).offset(offset).limit(limit)
stmt = stmt.options(selectinload(TechniquePage.creator))
# Apply sort ordering
if sort == "newest":
stmt = stmt.order_by(TechniquePage.created_at.desc())
elif sort == "oldest":
stmt = stmt.order_by(TechniquePage.created_at.asc())
elif sort == "creator":
stmt = stmt.join(Creator, TechniquePage.creator_id == Creator.id, isouter=True)
stmt = stmt.order_by(Creator.name.asc(), TechniquePage.title.asc())
else:
# Default: "alpha" — alphabetical by title
stmt = stmt.order_by(TechniquePage.title.asc())
stmt = stmt.offset(offset).limit(limit)
result = await db.execute(stmt)
pages = result.scalars().all()

View file

@ -180,6 +180,7 @@ class SearchService:
scope: str,
limit: int,
db: AsyncSession,
sort: str = "relevance",
) -> dict[str, list[dict[str, Any]]]:
"""Multi-token AND keyword search across technique pages, key moments, and creators.
@ -238,6 +239,7 @@ class SearchService:
"creator_id": str(tp.creator_id),
"creator_name": cr.name,
"creator_slug": cr.slug,
"created_at": tp.created_at.isoformat() if tp.created_at else "",
"score": 0.0,
})
@ -263,6 +265,7 @@ class SearchService:
"creator_id": str(cr.id),
"creator_name": cr.name,
"creator_slug": cr.slug,
"created_at": km.created_at.isoformat() if hasattr(km, "created_at") and km.created_at else "",
"score": 0.0,
})
@ -283,6 +286,7 @@ class SearchService:
"topic_category": "",
"topic_tags": cr.genres or [],
"creator_id": str(cr.id),
"created_at": cr.created_at.isoformat() if hasattr(cr, "created_at") and cr.created_at else "",
"score": 0.0,
})
@ -365,6 +369,7 @@ class SearchService:
scope: str,
limit: int,
db: AsyncSession,
sort: str = "relevance",
) -> dict[str, Any]:
"""Run semantic search with keyword fallback.
@ -404,13 +409,16 @@ class SearchService:
# Fallback to keyword search if semantic failed or returned nothing
if not items:
kw_result = await self.keyword_search(query, scope, limit, db)
kw_result = await self.keyword_search(query, scope, limit, db, sort=sort)
items = kw_result["items"]
partial_matches = kw_result.get("partial_matches", [])
fallback_used = True
else:
partial_matches = []
# Apply sort to enriched results (semantic or keyword)
items = self._apply_sort(items, sort)
elapsed_ms = (time.monotonic() - start) * 1000
logger.info(
@ -431,6 +439,33 @@ class SearchService:
"fallback_used": fallback_used,
}
# ── Sort helpers ────────────────────────────────────────────────────
@staticmethod
def _apply_sort(items: list[dict[str, Any]], sort: str) -> list[dict[str, Any]]:
"""Sort enriched result dicts by the requested criterion.
For 'relevance' (default), preserve existing order (score-based from
Qdrant or DB order from keyword search).
"""
if sort == "relevance" or not items:
return items
if sort == "newest":
# Sort by created_at descending; items without it go last
return sorted(items, key=lambda r: r.get("created_at", ""), reverse=True)
elif sort == "oldest":
# Sort by created_at ascending; items without it go last
return sorted(items, key=lambda r: r.get("created_at") or "9999", reverse=False)
elif sort == "alpha":
return sorted(items, key=lambda r: (r.get("title") or "").lower())
elif sort == "creator":
return sorted(
items,
key=lambda r: ((r.get("creator_name") or "").lower(), (r.get("title") or "").lower()),
)
return items
# ── Result enrichment ────────────────────────────────────────────────
async def _enrich_results(
@ -490,6 +525,7 @@ class SearchService:
"creator_id": cid,
"creator_name": creator_info["name"],
"creator_slug": creator_info["slug"],
"created_at": payload.get("created_at", ""),
"score": r.get("score", 0.0),
})