feat: Added scored dynamic related-techniques query returning up to 4 r…
- "backend/schemas.py" - "backend/routers/techniques.py" - "backend/tests/test_public_api.py" GSD-Task: S02/T01
This commit is contained in:
parent
6a793c2c9a
commit
c25db471f7
3 changed files with 303 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue