diff --git a/backend/routers/search.py b/backend/routers/search.py index 5ff137b..00bb613 100644 --- a/backend/routers/search.py +++ b/backend/routers/search.py @@ -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 (1–100, 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", [])], diff --git a/backend/routers/techniques.py b/backend/routers/techniques.py index 358c86b..a5eeb48 100644 --- a/backend/routers/techniques.py +++ b/backend/routers/techniques.py @@ -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) diff --git a/backend/routers/topics.py b/backend/routers/topics.py index 98dafc4..518dcaa 100644 --- a/backend/routers/topics.py +++ b/backend/routers/topics.py @@ -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() diff --git a/backend/search_service.py b/backend/search_service.py index 35fdf77..7b5c5c3 100644 --- a/backend/search_service.py +++ b/backend/search_service.py @@ -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), })