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:
parent
c1cdba14f2
commit
250d7315af
4 changed files with 80 additions and 6 deletions
|
|
@ -34,6 +34,7 @@ def _get_search_service() -> SearchService:
|
||||||
async def search(
|
async def search(
|
||||||
q: Annotated[str, Query(max_length=500)] = "",
|
q: Annotated[str, Query(max_length=500)] = "",
|
||||||
scope: Annotated[str, Query()] = "all",
|
scope: Annotated[str, Query()] = "all",
|
||||||
|
sort: Annotated[str, Query()] = "relevance",
|
||||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||||
db: AsyncSession = Depends(get_session),
|
db: AsyncSession = Depends(get_session),
|
||||||
) -> SearchResponse:
|
) -> SearchResponse:
|
||||||
|
|
@ -44,7 +45,7 @@ async def search(
|
||||||
- **limit**: Max results (1–100, default 20).
|
- **limit**: Max results (1–100, default 20).
|
||||||
"""
|
"""
|
||||||
svc = _get_search_service()
|
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(
|
return SearchResponse(
|
||||||
items=[SearchResultItem(**item) for item in result["items"]],
|
items=[SearchResultItem(**item) for item in result["items"]],
|
||||||
partial_matches=[SearchResultItem(**item) for item in result.get("partial_matches", [])],
|
partial_matches=[SearchResultItem(**item) for item in result.get("partial_matches", [])],
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,17 @@ async def list_techniques(
|
||||||
stmt = stmt.options(selectinload(TechniquePage.creator))
|
stmt = stmt.options(selectinload(TechniquePage.creator))
|
||||||
if sort == "random":
|
if sort == "random":
|
||||||
stmt = stmt.order_by(func.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:
|
else:
|
||||||
|
# Default: "recent" — newest first
|
||||||
stmt = stmt.order_by(TechniquePage.created_at.desc())
|
stmt = stmt.order_by(TechniquePage.created_at.desc())
|
||||||
stmt = stmt.offset(offset).limit(limit)
|
stmt = stmt.offset(offset).limit(limit)
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,7 @@ async def list_topics(
|
||||||
async def get_subtopic_techniques(
|
async def get_subtopic_techniques(
|
||||||
category_slug: str,
|
category_slug: str,
|
||||||
subtopic_slug: str,
|
subtopic_slug: str,
|
||||||
|
sort: Annotated[str, Query()] = "alpha",
|
||||||
offset: Annotated[int, Query(ge=0)] = 0,
|
offset: Annotated[int, Query(ge=0)] = 0,
|
||||||
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
||||||
db: AsyncSession = Depends(get_session),
|
db: AsyncSession = Depends(get_session),
|
||||||
|
|
@ -132,10 +133,21 @@ async def get_subtopic_techniques(
|
||||||
|
|
||||||
stmt = (
|
stmt = (
|
||||||
stmt.options(selectinload(TechniquePage.creator))
|
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)
|
result = await db.execute(stmt)
|
||||||
pages = result.scalars().all()
|
pages = result.scalars().all()
|
||||||
|
|
||||||
|
|
@ -158,6 +170,7 @@ async def get_subtopic_techniques(
|
||||||
@router.get("/{category_slug}", response_model=PaginatedResponse)
|
@router.get("/{category_slug}", response_model=PaginatedResponse)
|
||||||
async def get_topic_techniques(
|
async def get_topic_techniques(
|
||||||
category_slug: str,
|
category_slug: str,
|
||||||
|
sort: Annotated[str, Query()] = "alpha",
|
||||||
offset: Annotated[int, Query(ge=0)] = 0,
|
offset: Annotated[int, Query(ge=0)] = 0,
|
||||||
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
||||||
db: AsyncSession = Depends(get_session),
|
db: AsyncSession = Depends(get_session),
|
||||||
|
|
@ -179,7 +192,21 @@ async def get_topic_techniques(
|
||||||
count_result = await db.execute(count_stmt)
|
count_result = await db.execute(count_stmt)
|
||||||
total = count_result.scalar() or 0
|
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)
|
result = await db.execute(stmt)
|
||||||
pages = result.scalars().all()
|
pages = result.scalars().all()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,7 @@ class SearchService:
|
||||||
scope: str,
|
scope: str,
|
||||||
limit: int,
|
limit: int,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
|
sort: str = "relevance",
|
||||||
) -> dict[str, list[dict[str, Any]]]:
|
) -> dict[str, list[dict[str, Any]]]:
|
||||||
"""Multi-token AND keyword search across technique pages, key moments, and creators.
|
"""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_id": str(tp.creator_id),
|
||||||
"creator_name": cr.name,
|
"creator_name": cr.name,
|
||||||
"creator_slug": cr.slug,
|
"creator_slug": cr.slug,
|
||||||
|
"created_at": tp.created_at.isoformat() if tp.created_at else "",
|
||||||
"score": 0.0,
|
"score": 0.0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -263,6 +265,7 @@ class SearchService:
|
||||||
"creator_id": str(cr.id),
|
"creator_id": str(cr.id),
|
||||||
"creator_name": cr.name,
|
"creator_name": cr.name,
|
||||||
"creator_slug": cr.slug,
|
"creator_slug": cr.slug,
|
||||||
|
"created_at": km.created_at.isoformat() if hasattr(km, "created_at") and km.created_at else "",
|
||||||
"score": 0.0,
|
"score": 0.0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -283,6 +286,7 @@ class SearchService:
|
||||||
"topic_category": "",
|
"topic_category": "",
|
||||||
"topic_tags": cr.genres or [],
|
"topic_tags": cr.genres or [],
|
||||||
"creator_id": str(cr.id),
|
"creator_id": str(cr.id),
|
||||||
|
"created_at": cr.created_at.isoformat() if hasattr(cr, "created_at") and cr.created_at else "",
|
||||||
"score": 0.0,
|
"score": 0.0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -365,6 +369,7 @@ class SearchService:
|
||||||
scope: str,
|
scope: str,
|
||||||
limit: int,
|
limit: int,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
|
sort: str = "relevance",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Run semantic search with keyword fallback.
|
"""Run semantic search with keyword fallback.
|
||||||
|
|
||||||
|
|
@ -404,13 +409,16 @@ class SearchService:
|
||||||
|
|
||||||
# Fallback to keyword search if semantic failed or returned nothing
|
# Fallback to keyword search if semantic failed or returned nothing
|
||||||
if not items:
|
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"]
|
items = kw_result["items"]
|
||||||
partial_matches = kw_result.get("partial_matches", [])
|
partial_matches = kw_result.get("partial_matches", [])
|
||||||
fallback_used = True
|
fallback_used = True
|
||||||
else:
|
else:
|
||||||
partial_matches = []
|
partial_matches = []
|
||||||
|
|
||||||
|
# Apply sort to enriched results (semantic or keyword)
|
||||||
|
items = self._apply_sort(items, sort)
|
||||||
|
|
||||||
elapsed_ms = (time.monotonic() - start) * 1000
|
elapsed_ms = (time.monotonic() - start) * 1000
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -431,6 +439,33 @@ class SearchService:
|
||||||
"fallback_used": fallback_used,
|
"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 ────────────────────────────────────────────────
|
# ── Result enrichment ────────────────────────────────────────────────
|
||||||
|
|
||||||
async def _enrich_results(
|
async def _enrich_results(
|
||||||
|
|
@ -490,6 +525,7 @@ class SearchService:
|
||||||
"creator_id": cid,
|
"creator_id": cid,
|
||||||
"creator_name": creator_info["name"],
|
"creator_name": creator_info["name"],
|
||||||
"creator_slug": creator_info["slug"],
|
"creator_slug": creator_info["slug"],
|
||||||
|
"created_at": payload.get("created_at", ""),
|
||||||
"score": r.get("score", 0.0),
|
"score": r.get("score", 0.0),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue