diff --git a/backend/routers/techniques.py b/backend/routers/techniques.py index 36f19bd..0c2dbea 100644 --- a/backend/routers/techniques.py +++ b/backend/routers/techniques.py @@ -29,6 +29,87 @@ logger = logging.getLogger("chrysopedia.techniques") router = APIRouter(prefix="/techniques", tags=["techniques"]) +async def _find_dynamic_related( + db: AsyncSession, + page: TechniquePage, + exclude_slugs: set[str], + limit: int, +) -> list[RelatedLinkItem]: + """Score and return dynamically related technique pages. + + Scoring: + - Same creator + same topic_category: +3 + - Same creator, different category: +2 + - Same topic_category, different creator: +2 + - Each overlapping topic_tag: +1 + """ + exclude_ids = {page.id} + + # Base: all other technique pages, eagerly load creator for name + stmt = ( + select(TechniquePage) + .options(selectinload(TechniquePage.creator)) + .where(TechniquePage.id != page.id) + ) + if exclude_slugs: + stmt = stmt.where(TechniquePage.slug.notin_(exclude_slugs)) + + result = await db.execute(stmt) + candidates = result.scalars().all() + + if not candidates: + return [] + + current_tags = set(page.topic_tags) if page.topic_tags else set() + + scored: list[tuple[int, str, TechniquePage]] = [] + for cand in candidates: + score = 0 + reasons: list[str] = [] + + same_creator = cand.creator_id == page.creator_id + same_category = cand.topic_category == page.topic_category + + if same_creator and same_category: + score += 3 + reasons.append("Same creator, same topic") + elif same_creator: + score += 2 + reasons.append("Same creator") + elif same_category: + score += 2 + reasons.append(f"Also about {page.topic_category}") + + # Tag overlap scoring + if current_tags: + cand_tags = set(cand.topic_tags) if cand.topic_tags else set() + shared = current_tags & cand_tags + if shared: + score += len(shared) + reasons.append(f"Shared tags: {', '.join(sorted(shared))}") + + if score > 0: + scored.append((score, "; ".join(reasons), cand)) + + # Sort descending by score, then by title for determinism + scored.sort(key=lambda x: (-x[0], x[2].title)) + + results: list[RelatedLinkItem] = [] + for score, reason, cand in scored[:limit]: + creator_name = cand.creator.name if cand.creator else "" + results.append( + RelatedLinkItem( + target_title=cand.title, + target_slug=cand.slug, + relationship="dynamic", + creator_name=creator_name, + topic_category=cand.topic_category, + reason=reason, + ) + ) + return results + + @router.get("", response_model=PaginatedResponse) async def list_techniques( category: Annotated[str | None, Query()] = None, @@ -165,6 +246,23 @@ async def get_technique( ) ) + # Supplement with dynamic related techniques (up to 4 total) + curated_slugs = {link.target_slug for link in related_links} + max_related = 4 + if len(related_links) < max_related: + remaining = max_related - len(related_links) + try: + dynamic_links = await _find_dynamic_related( + db, page, curated_slugs, remaining + ) + related_links.extend(dynamic_links) + except Exception: + logger.warning( + "Dynamic related query failed for %s, continuing with curated only", + slug, + exc_info=True, + ) + base = TechniquePageRead.model_validate(page) # Count versions for this page diff --git a/backend/schemas.py b/backend/schemas.py index c46b417..f03141b 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -247,6 +247,9 @@ class RelatedLinkItem(BaseModel): target_title: str = "" target_slug: str = "" relationship: str = "" + creator_name: str = "" + topic_category: str = "" + reason: str = "" class CreatorInfo(BaseModel): diff --git a/backend/tests/test_public_api.py b/backend/tests/test_public_api.py index fdddc92..0cf0183 100644 --- a/backend/tests/test_public_api.py +++ b/backend/tests/test_public_api.py @@ -594,3 +594,205 @@ async def test_technique_detail_includes_version_count(client, db_engine): resp2 = await client.get(f"{TECHNIQUES_URL}/{seed['tp1_slug']}") assert resp2.status_code == 200 assert resp2.json()["version_count"] == 1 + + +# ── Dynamic Related Techniques Tests ───────────────────────────────────────── + + +async def _seed_related_data(db_engine) -> dict: + """Seed 2 creators and 5 technique pages with overlapping tags/categories. + + Returns a dict of slugs and metadata for related-technique assertions. + """ + session_factory = async_sessionmaker( + db_engine, class_=AsyncSession, expire_on_commit=False + ) + async with session_factory() as session: + creator_a = Creator( + name="Creator A", + slug="creator-a", + genres=["Ambient"], + folder_name="CreatorA", + ) + creator_b = Creator( + name="Creator B", + slug="creator-b", + genres=["Techno"], + folder_name="CreatorB", + ) + session.add_all([creator_a, creator_b]) + await session.flush() + + # tp1: creator_a, "Sound design", tags: [reverb, delay] + tp1 = TechniquePage( + creator_id=creator_a.id, + title="Reverb Chains", + slug="reverb-chains", + topic_category="Sound design", + topic_tags=["reverb", "delay"], + summary="Chaining reverbs for depth", + ) + # tp2: creator_a, "Sound design", tags: [reverb, modulation] + # Same creator + same category = score 3, shared tag 'reverb' = +1 → 4 + tp2 = TechniquePage( + creator_id=creator_a.id, + title="Advanced Reverb Modulation", + slug="advanced-reverb-modulation", + topic_category="Sound design", + topic_tags=["reverb", "modulation"], + summary="Modulating reverb tails", + ) + # tp3: creator_a, "Mixing", tags: [delay, sidechain] + # Same creator, different category = score 2, shared tag 'delay' = +1 → 3 + tp3 = TechniquePage( + creator_id=creator_a.id, + title="Delay Mixing Tricks", + slug="delay-mixing-tricks", + topic_category="Mixing", + topic_tags=["delay", "sidechain"], + summary="Using delay in mix context", + ) + # tp4: creator_b, "Sound design", tags: [reverb] + # Different creator, same category = score 2, shared tag 'reverb' = +1 → 3 + tp4 = TechniquePage( + creator_id=creator_b.id, + title="Plate Reverb Techniques", + slug="plate-reverb-techniques", + topic_category="Sound design", + topic_tags=["reverb"], + summary="Plate reverb for vocals", + ) + # tp5: creator_b, "Mastering", tags: [limiting] + # Different creator, different category, no shared tags = score 0 + tp5 = TechniquePage( + creator_id=creator_b.id, + title="Mastering Limiter Setup", + slug="mastering-limiter-setup", + topic_category="Mastering", + topic_tags=["limiting"], + summary="Setting up a mastering limiter", + ) + session.add_all([tp1, tp2, tp3, tp4, tp5]) + await session.commit() + + return { + "tp1_slug": tp1.slug, + "tp2_slug": tp2.slug, + "tp3_slug": tp3.slug, + "tp4_slug": tp4.slug, + "tp5_slug": tp5.slug, + } + + +@pytest.mark.asyncio +async def test_dynamic_related_techniques(client, db_engine): + """Dynamic related links are scored and ranked correctly.""" + seed = await _seed_related_data(db_engine) + + resp = await client.get(f"{TECHNIQUES_URL}/{seed['tp1_slug']}") + assert resp.status_code == 200 + + data = resp.json() + related = data["related_links"] + + # tp1 should match tp2 (score 4), tp3 (score 3), tp4 (score 3), NOT tp5 (score 0) + related_slugs = [r["target_slug"] for r in related] + assert seed["tp5_slug"] not in related_slugs + assert len(related) <= 4 + + # tp2 should be first (highest score: same creator + same category + shared tag) + assert related[0]["target_slug"] == seed["tp2_slug"] + + # All results should have enriched fields populated + for r in related: + assert r["creator_name"] != "" + assert r["topic_category"] != "" + assert r["reason"] != "" + + +@pytest.mark.asyncio +async def test_dynamic_related_excludes_self(client, db_engine): + """The technique itself never appears in its own related_links.""" + seed = await _seed_related_data(db_engine) + + resp = await client.get(f"{TECHNIQUES_URL}/{seed['tp1_slug']}") + assert resp.status_code == 200 + + related_slugs = {r["target_slug"] for r in resp.json()["related_links"]} + assert seed["tp1_slug"] not in related_slugs + + +@pytest.mark.asyncio +async def test_dynamic_related_no_peers(client, db_engine): + """A technique with no matching peers returns empty related_links.""" + session_factory = async_sessionmaker( + db_engine, class_=AsyncSession, expire_on_commit=False + ) + async with session_factory() as session: + creator = Creator( + name="Solo Artist", + slug="solo-artist", + genres=["Noise"], + folder_name="SoloArtist", + ) + session.add(creator) + await session.flush() + + tp = TechniquePage( + creator_id=creator.id, + title="Unique Technique", + slug="unique-technique", + topic_category="Experimental", + topic_tags=["unique-tag-xyz"], + summary="Completely unique", + ) + session.add(tp) + await session.commit() + + resp = await client.get(f"{TECHNIQUES_URL}/unique-technique") + assert resp.status_code == 200 + assert resp.json()["related_links"] == [] + + +@pytest.mark.asyncio +async def test_dynamic_related_null_tags(client, db_engine): + """Technique with NULL topic_tags still scores on creator_id/topic_category.""" + session_factory = async_sessionmaker( + db_engine, class_=AsyncSession, expire_on_commit=False + ) + async with session_factory() as session: + creator = Creator( + name="Tag-Free Creator", + slug="tag-free-creator", + genres=["Pop"], + folder_name="TagFreeCreator", + ) + session.add(creator) + await session.flush() + + tp_main = TechniquePage( + creator_id=creator.id, + title="No Tags Main", + slug="no-tags-main", + topic_category="Production", + topic_tags=None, + summary="Main page with no tags", + ) + tp_peer = TechniquePage( + creator_id=creator.id, + title="No Tags Peer", + slug="no-tags-peer", + topic_category="Production", + topic_tags=None, + summary="Peer page, same creator and category", + ) + session.add_all([tp_main, tp_peer]) + await session.commit() + + resp = await client.get(f"{TECHNIQUES_URL}/no-tags-main") + assert resp.status_code == 200 + + related = resp.json()["related_links"] + assert len(related) == 1 + assert related[0]["target_slug"] == "no-tags-peer" + assert related[0]["reason"] == "Same creator, same topic"