chrysopedia/backend/routers/topics.py
jlightner e0c73db8ff 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
2026-04-01 06:41:52 +00:00

226 lines
7.4 KiB
Python

"""Topics endpoint — two-level category hierarchy with aggregated counts."""
from __future__ import annotations
import logging
import os
from typing import Annotated, Any
import yaml
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from database import get_session
from models import Creator, TechniquePage
from schemas import (
PaginatedResponse,
TechniquePageRead,
TopicCategory,
TopicSubTopic,
)
logger = logging.getLogger("chrysopedia.topics")
router = APIRouter(prefix="/topics", tags=["topics"])
# Path to canonical_tags.yaml relative to the backend directory
_TAGS_PATH = os.path.join(os.path.dirname(__file__), "..", "..", "config", "canonical_tags.yaml")
def _load_canonical_tags() -> list[dict[str, Any]]:
"""Load the canonical tag categories from YAML."""
path = os.path.normpath(_TAGS_PATH)
try:
with open(path) as f:
data = yaml.safe_load(f)
return data.get("categories", [])
except FileNotFoundError:
logger.warning("canonical_tags.yaml not found at %s", path)
return []
@router.get("", response_model=list[TopicCategory])
async def list_topics(
db: AsyncSession = Depends(get_session),
) -> list[TopicCategory]:
"""Return the two-level topic hierarchy with technique/creator counts per sub-topic.
Categories come from ``canonical_tags.yaml``. Counts are computed
from live DB data by matching ``topic_tags`` array contents.
"""
categories = _load_canonical_tags()
# Pre-fetch all technique pages with their tags and creator_ids for counting
tp_stmt = select(
TechniquePage.topic_category,
TechniquePage.topic_tags,
TechniquePage.creator_id,
)
tp_result = await db.execute(tp_stmt)
tp_rows = tp_result.all()
# Build per-sub-topic counts
result: list[TopicCategory] = []
for cat in categories:
cat_name = cat.get("name", "")
cat_desc = cat.get("description", "")
sub_topic_names: list[str] = cat.get("sub_topics", [])
sub_topics: list[TopicSubTopic] = []
for st_name in sub_topic_names:
technique_count = 0
creator_ids: set[str] = set()
for tp_cat, tp_tags, tp_creator_id in tp_rows:
tags = tp_tags or []
# Match if the sub-topic name appears in the technique's tags
# or if the category matches and tag is in sub-topics
if st_name.lower() in [t.lower() for t in tags]:
technique_count += 1
creator_ids.add(str(tp_creator_id))
sub_topics.append(
TopicSubTopic(
name=st_name,
technique_count=technique_count,
creator_count=len(creator_ids),
)
)
result.append(
TopicCategory(
name=cat_name,
description=cat_desc,
sub_topics=sub_topics,
)
)
return result
@router.get("/{category_slug}/{subtopic_slug}", response_model=PaginatedResponse)
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),
) -> PaginatedResponse:
"""Return technique pages filtered by sub-topic tag within a category.
``subtopic_slug`` is matched case-insensitively against elements of the
``topic_tags`` ARRAY column. ``category_slug`` is normalised and matched
against ``topic_category`` to scope results to the correct category.
Results are eager-loaded with the creator relation.
"""
category_name = category_slug.replace("-", " ").title()
subtopic_name = subtopic_slug.replace("-", " ")
# Filter: category matches AND subtopic_name appears in topic_tags.
# Tags are stored lowercase; slugs arrive lowercase. Use PG ARRAY contains (@>).
stmt = (
select(TechniquePage)
.where(TechniquePage.topic_category.ilike(category_name))
.where(TechniquePage.topic_tags.contains([subtopic_name]))
)
count_stmt = select(func.count()).select_from(stmt.subquery())
count_result = await db.execute(count_stmt)
total = count_result.scalar() or 0
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()
items = []
for p in pages:
item = TechniquePageRead.model_validate(p)
if p.creator:
item.creator_name = p.creator.name
item.creator_slug = p.creator.slug
items.append(item)
return PaginatedResponse(
items=items,
total=total,
offset=offset,
limit=limit,
)
@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),
) -> PaginatedResponse:
"""Return technique pages filtered by topic_category.
The ``category_slug`` is matched case-insensitively against
``technique_pages.topic_category`` (e.g. 'sound-design' matches 'Sound design').
"""
# Normalize slug to category name: replace hyphens with spaces, title-case
category_name = category_slug.replace("-", " ").title()
# Also try exact match on the slug form
stmt = select(TechniquePage).where(
TechniquePage.topic_category.ilike(category_name)
)
count_stmt = select(func.count()).select_from(stmt.subquery())
count_result = await db.execute(count_stmt)
total = count_result.scalar() or 0
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()
items = []
for p in pages:
item = TechniquePageRead.model_validate(p)
if p.creator:
item.creator_name = p.creator.name
item.creator_slug = p.creator.slug
items.append(item)
return PaginatedResponse(
items=items,
total=total,
offset=offset,
limit=limit,
)