Precompute discovery ring per shop for deterministic content traversal

Replace per-request Jaccard similarity with a precomputed circular
ordering of all public products. Greedy nearest-neighbor walk starts
from the newest product, accumulating stems to cluster similar content.
Ring reforges on product create/edit/upload when watch mode is enabled.

Frontend stores ring position, direction, and watch history in
localStorage. Queue items splice into playback without moving ring
position. Watched items dim in sidebar with progress indicator.
This commit is contained in:
russell@unturf.com 2026-02-08 16:07:25 -05:00
parent 4c1cd11050
commit bc41c3481b
9 changed files with 516 additions and 74 deletions

View file

@ -697,6 +697,40 @@ def tokenize_and_stem(text):
return {stem_word(w) for w in words}
def get_ring_related_products(product, ring, limit=8):
"""Get related products by walking forward from current position in the ring.
Returns a list of Product objects in ring order starting after the current product.
Falls back to get_related_products if product not in ring.
"""
product_id_str = str(product.id)
if product_id_str not in ring:
return get_related_products(product, limit=limit)
idx = ring.index(product_id_str)
ring_len = len(ring)
# Walk forward from current position, wrapping around
related_ids = []
for i in range(1, ring_len):
next_idx = (idx + i) % ring_len
related_ids.append(ring[next_idx])
if len(related_ids) >= limit:
break
if not related_ids:
return []
# Fetch products and preserve ring order
products = get_products_by_ids(product.dbsession, related_ids)
if not products:
return []
# Build lookup by ID string for ordering
product_map = {str(p.id): p for p in products}
return [product_map[pid] for pid in related_ids if pid in product_map]
def get_related_products(product, limit=8):
"""Find related products in same shop using tiered fallback:
1. Stem overlap (highest score first)

View file

@ -1,3 +1,4 @@
import json
import uuid
from sqlalchemy import Column, BigInteger, Boolean, Unicode, UnicodeText, func
@ -133,6 +134,9 @@ class Shop(RBase, Base):
# Email digest subscriptions
subscriptions_enabled = Column(Boolean, default=True)
# Precomputed discovery ring: circular ordering of all public products
json_discovery_ring = Column(UnicodeText, nullable=True)
# many to many uses association_proxy.
users = association_proxy("shop_users", "user", creator=lambda u: UserShop(user=u))
@ -581,6 +585,108 @@ class Shop(RBase, Base):
def stamp_updated_timestamp(self):
self.updated_timestamp = now_timestamp()
@property
def discovery_ring(self):
"""Return the discovery ring as a list of product ID strings, or []."""
if not hasattr(self, "_discovery_ring"):
if self.json_discovery_ring:
self._discovery_ring = json.loads(self.json_discovery_ring)
else:
self._discovery_ring = []
return self._discovery_ring
@discovery_ring.setter
def discovery_ring(self, ring_list):
"""Set the discovery ring from a list of product ID strings."""
self._discovery_ring = ring_list
self.json_discovery_ring = json.dumps(ring_list)
def compute_discovery_ring(shop):
"""Compute a circular ordering of all public products using greedy nearest-neighbor.
Algorithm:
1. Start from the newest public product
2. Accumulate its stems into a running set
3. Find unvisited product with highest Jaccard similarity to accumulated stems
4. Add to ring, merge stems, repeat until all public products are ordered
Returns a list of product ID strings.
"""
from .product import tokenize_and_stem
# Gather all public products, sorted newest-first for deterministic seed
candidates = []
for p in shop.products:
if p.visibility == 1:
candidates.append(p)
if not candidates:
return []
# Sort newest-first; tiebreak by ID for determinism
candidates.sort(key=lambda p: (-p.created_timestamp, str(p.id)))
if len(candidates) == 1:
return [str(candidates[0].id)]
# Pre-compute stems for all candidates
stems_map = {}
for p in candidates:
stems_map[p.id] = tokenize_and_stem(
(p.title or '') + ' ' + (p.description or '')
)
# Greedy walk
ring = []
visited = set()
accumulated_stems = set()
# Start with newest product
current = candidates[0]
ring.append(str(current.id))
visited.add(current.id)
accumulated_stems |= stems_map[current.id]
while len(ring) < len(candidates):
best_score = -1
best_candidate = None
for p in candidates:
if p.id in visited:
continue
p_stems = stems_map[p.id]
union = accumulated_stems | p_stems
if union:
score = len(accumulated_stems & p_stems) / len(union)
else:
score = 0
# Tiebreak: highest score, then newest, then ID
if (score > best_score or
(score == best_score and best_candidate is not None and
(p.created_timestamp > best_candidate.created_timestamp or
(p.created_timestamp == best_candidate.created_timestamp and
str(p.id) < str(best_candidate.id))))):
best_score = score
best_candidate = p
if best_candidate is None:
break
ring.append(str(best_candidate.id))
visited.add(best_candidate.id)
accumulated_stems |= stems_map[best_candidate.id]
return ring
def reforge_discovery_ring(shop):
"""Compute and store the discovery ring on the shop."""
ring = compute_discovery_ring(shop)
shop.discovery_ring = ring
return ring
def is_shop_name_available(dbsession, name):
return not _shop_by_name_query(dbsession, unicode(name)).count()

View file

@ -0,0 +1,36 @@
"""add discovery ring to shop
Revision ID: 05f7eb8cecf1
Revises: c3f9d1cdffb4
Create Date: 2026-02-08 15:27:04.955833
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '05f7eb8cecf1'
down_revision = 'c3f9d1cdffb4'
branch_labels = None
depends_on = None
from make_post_sell.models.meta import UUIDType
def _column_exists(table, column):
conn = op.get_bind()
result = conn.execute(sa.text(f"PRAGMA table_info({table})"))
return any(row[1] == column for row in result.fetchall())
def upgrade():
if not _column_exists("mps_shop", "json_discovery_ring"):
op.add_column(
"mps_shop",
sa.Column("json_discovery_ring", sa.UnicodeText(), nullable=True),
)
def downgrade():
op.drop_column("mps_shop", "json_discovery_ring")

View file

@ -2739,6 +2739,30 @@ textarea {
background: var(--bg-secondary, rgba(0, 0, 0, 0.04));
}
.related-content-row-watched {
opacity: 0.45;
}
.ring-progress {
font-size: 0.8em;
color: var(--text-muted, #999);
font-variant-numeric: tabular-nums;
}
.watch-direction-label {
display: grid;
grid-template-columns: auto auto;
gap: 6px;
align-items: center;
font-size: 0.85em;
cursor: pointer;
}
.direction-label-text {
font-size: 0.85em;
color: var(--text-muted, #999);
}
.related-content-index {
font-size: 0.75em;
color: var(--text-muted, #999);

View file

@ -16,6 +16,75 @@
var BUFFER_AHEAD_SECONDS = 30;
var bufferingStarted = false;
// --- Ring state (persisted in localStorage) ---
var ringProductIds = [];
var ringPosition = 0;
var ringDirection = 1; // 1 = forward, -1 = reverse
var ringHistory = {}; // { productId: true } — set of watched IDs
// Load ring state from localStorage
try {
var savedRing = localStorage.getItem('watchRing');
if (savedRing) ringProductIds = JSON.parse(savedRing);
} catch (e) {}
try {
var savedPos = localStorage.getItem('watchRingPosition');
if (savedPos !== null) ringPosition = parseInt(savedPos, 10) || 0;
} catch (e) {}
try {
var savedDir = localStorage.getItem('watchRingDirection');
if (savedDir !== null) ringDirection = parseInt(savedDir, 10) || 1;
} catch (e) {}
try {
var savedHistory = localStorage.getItem('watchRingHistory');
if (savedHistory) ringHistory = JSON.parse(savedHistory);
} catch (e) {}
function saveRingState() {
try {
localStorage.setItem('watchRing', JSON.stringify(ringProductIds));
localStorage.setItem('watchRingPosition', String(ringPosition));
localStorage.setItem('watchRingDirection', String(ringDirection));
localStorage.setItem('watchRingHistory', JSON.stringify(ringHistory));
} catch (e) {}
}
function markWatched(productId) {
if (!productId) return;
ringHistory[productId] = true;
saveRingState();
updateProgressDisplay();
}
function isWatched(productId) {
return !!ringHistory[productId];
}
// Sync ring position to the current product
function syncRingPosition(productId) {
if (!ringProductIds.length || !productId) return;
var idx = ringProductIds.indexOf(productId);
if (idx !== -1) {
ringPosition = idx;
saveRingState();
}
}
// Update progress display ("23 / 87")
function updateProgressDisplay() {
var el = document.getElementById('ring-progress');
if (!el) return;
if (!ringProductIds.length) {
el.textContent = '';
return;
}
var watched = 0;
for (var id in ringHistory) {
if (ringProductIds.indexOf(id) !== -1) watched++;
}
el.textContent = watched + ' / ' + ringProductIds.length;
}
// Autoplay preference (persisted in localStorage)
var autoplayEnabled = true;
try {
@ -46,48 +115,12 @@
if (savedQueue) queue = JSON.parse(savedQueue);
} catch (e) {}
// --- Recently played tracking (localStorage) ---
var RECENTLY_PLAYED_COOLDOWN = 4 * 60 * 60 * 1000; // 4 hours
var recentlyPlayed = {};
try {
var savedRecent = localStorage.getItem('watchRecentlyPlayed');
if (savedRecent) recentlyPlayed = JSON.parse(savedRecent);
} catch (e) {}
function cleanRecentlyPlayed() {
var now = Date.now();
var changed = false;
for (var id in recentlyPlayed) {
if (now - recentlyPlayed[id] > RECENTLY_PLAYED_COOLDOWN) {
delete recentlyPlayed[id];
changed = true;
}
}
if (changed) saveRecentlyPlayed();
}
function saveRecentlyPlayed() {
try { localStorage.setItem('watchRecentlyPlayed', JSON.stringify(recentlyPlayed)); } catch (e) {}
}
function markPlayed(productId) {
if (!productId) return;
recentlyPlayed[productId] = Date.now();
saveRecentlyPlayed();
}
function isRecentlyPlayed(productId) {
if (!recentlyPlayed[productId]) return false;
return (Date.now() - recentlyPlayed[productId]) < RECENTLY_PLAYED_COOLDOWN;
}
// Clean stale entries on load
cleanRecentlyPlayed();
// Mark current item as played
// Mark current item as watched and sync ring position
var initialProductEl = document.querySelector('[data-watch-product-id]');
if (initialProductEl) {
markPlayed(initialProductEl.getAttribute('data-watch-product-id'));
var initialId = initialProductEl.getAttribute('data-watch-product-id');
markWatched(initialId);
syncRingPosition(initialId);
}
// --- Autoplay with sound (for video/audio) ---
@ -382,7 +415,13 @@
// --- Update page content (title, description, related, URL) ---
function updatePageContent(data) {
markPlayed(data.product_id);
// Update ring from server response
if (data.ring && data.ring.length) {
ringProductIds = data.ring;
}
markWatched(data.product_id);
syncRingPosition(data.product_id);
document.title = data.title;
var h1 = document.querySelector('.product-images h1');
@ -419,27 +458,34 @@
}
var autoplayChecked = autoplayEnabled ? ' checked' : '';
var directionChecked = ringDirection === -1 ? ' checked' : '';
var headerHtml = '<div class="related-content-header">'
+ '<h3>Up Next</h3>'
+ '<span id="ring-progress" class="ring-progress"></span>'
+ '<label class="watch-autoplay-label js-only">'
+ '<span class="autoplay-label-text">Autoplay</span>'
+ '<input type="checkbox" id="watch-autoplay-toggle"' + autoplayChecked + ' />'
+ '<span class="autoplay-slider"></span>'
+ '</label>'
+ '<label class="watch-direction-label js-only">'
+ '<span class="direction-label-text">Reverse</span>'
+ '<input type="checkbox" id="watch-direction-toggle"' + directionChecked + ' />'
+ '<span class="autoplay-slider"></span>'
+ '</label></div>';
if (!related || related.length === 0) {
container.innerHTML = headerHtml + '<p>No more items</p>';
rebindAutoplayToggle();
rebindToggles();
return;
}
var html = headerHtml;
var visibleIndex = 0;
related.forEach(function(item) {
var played = isRecentlyPlayed(item.id);
if (played) return; // skip recently played
visibleIndex++;
html += '<div class="related-content-row">';
var watched = isWatched(item.id);
var dimClass = watched ? ' related-content-row-watched' : '';
html += '<div class="related-content-row' + dimClass + '">';
html += '<span class="related-content-index">' + visibleIndex + '</span>';
html += '<a href="' + item.url + '" class="related-content-item" data-watch-id="' + item.id + '">';
if (item.thumbnail_url) {
@ -454,10 +500,10 @@
});
container.innerHTML = html;
rebindAutoplayToggle();
rebindToggles();
}
function rebindAutoplayToggle() {
function rebindToggles() {
autoplayToggle = document.getElementById('watch-autoplay-toggle');
if (autoplayToggle) {
autoplayToggle.checked = autoplayEnabled;
@ -472,6 +518,17 @@
}
});
}
var directionToggle = document.getElementById('watch-direction-toggle');
if (directionToggle) {
directionToggle.checked = (ringDirection === -1);
directionToggle.addEventListener('change', function() {
ringDirection = this.checked ? -1 : 1;
saveRingState();
});
}
updateProgressDisplay();
}
function escapeHtml(text) {
@ -480,25 +537,38 @@
return div.innerHTML;
}
// --- Countdown timer ---
// --- Ring-based getNextItem ---
function getNextItem() {
// Queue items always play (user explicitly added them)
// Queue items splice into current position — play queue first
if (queue.length > 0) return queue[0];
// Find first related item that hasn't been recently played
var allRelated = document.querySelectorAll('.related-content-item[data-watch-id]');
for (var i = 0; i < allRelated.length; i++) {
var el = allRelated[i];
var id = el.getAttribute('data-watch-id');
if (!isRecentlyPlayed(id)) {
// Advance ring position by direction, return that item
if (!ringProductIds.length) {
// No ring — fall back to first related item in DOM
var allRelated = document.querySelectorAll('.related-content-item[data-watch-id]');
if (allRelated.length > 0) {
var el = allRelated[0];
return {
id: id,
id: el.getAttribute('data-watch-id'),
title: el.querySelector('span') ? el.querySelector('span').textContent : '',
thumbnail_url: el.querySelector('img') ? el.querySelector('img').src : null,
url: el.getAttribute('href')
};
}
return null;
}
return null;
var nextPos = (ringPosition + ringDirection + ringProductIds.length) % ringProductIds.length;
var nextId = ringProductIds[nextPos];
// Find the related item in the DOM for title/thumbnail
var el = document.querySelector('[data-watch-id="' + nextId + '"]');
return {
id: nextId,
title: el ? (el.querySelector('span') ? el.querySelector('span').textContent : '') : '',
thumbnail_url: el ? (el.querySelector('img') ? el.querySelector('img').src : null) : null,
url: el ? el.getAttribute('href') : '/c/' + nextId
};
}
function startCountdown() {
@ -551,10 +621,17 @@
if (!next || !next.id) return;
var productId = next.id;
// If this came from the queue, pop it (ring position unchanged)
if (queue.length > 0 && queue[0].id === productId) {
queue.shift();
saveQueue();
renderQueue();
} else {
// Advance ring position
if (ringProductIds.length) {
ringPosition = (ringPosition + ringDirection + ringProductIds.length) % ringProductIds.length;
saveRingState();
}
}
if (preloadedData && preloadedData.product_id === productId) {
@ -692,22 +769,18 @@
);
}
// Filter recently played from server-rendered related content
function filterRecentlyPlayedFromDOM() {
// Dim already-watched items in server-rendered related content
function dimWatchedInDOM() {
var rows = document.querySelectorAll('.related-content-row');
var visibleIndex = 0;
for (var i = 0; i < rows.length; i++) {
var link = rows[i].querySelector('.related-content-item[data-watch-id]');
if (link && isRecentlyPlayed(link.getAttribute('data-watch-id'))) {
rows[i].style.display = 'none';
} else {
visibleIndex++;
var indexEl = rows[i].querySelector('.related-content-index');
if (indexEl) indexEl.textContent = visibleIndex;
if (link && isWatched(link.getAttribute('data-watch-id'))) {
rows[i].classList.add('related-content-row-watched');
}
}
}
filterRecentlyPlayedFromDOM();
dimWatchedInDOM();
updateProgressDisplay();
renderQueue();
preloadNext();

View file

@ -2367,6 +2367,141 @@ class TestStemmer(unittest.TestCase):
)
class TestDiscoveryRing(unittest.TestCase):
"""Test the discovery ring precomputation algorithm."""
def _make_shop(self):
return Shop(
"test-shop",
"555-555-5555",
"123 Test St",
"Test shop description",
)
def _make_product(self, title, desc, timestamp, visibility=1):
from ..models.product import Product
p = Product(title, desc)
p.visibility = visibility
p.created_timestamp = timestamp
return p
def test_empty_shop(self):
"""Empty shop returns empty ring."""
from ..models.shop import compute_discovery_ring
shop = self._make_shop()
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
mp.return_value = []
self.assertEqual(compute_discovery_ring(shop), [])
def test_single_product(self):
"""Single public product returns ring of one."""
from ..models.shop import compute_discovery_ring
shop = self._make_shop()
p = self._make_product("Solo", "Only item", 100)
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
mp.return_value = [p]
ring = compute_discovery_ring(shop)
self.assertEqual(ring, [str(p.id)])
def test_all_public_products_included(self):
"""Every public product appears exactly once, no dupes."""
from ..models.shop import compute_discovery_ring
shop = self._make_shop()
products = [self._make_product(f"Item {i}", f"Description {i}", 100 + i) for i in range(10)]
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
mp.return_value = products
ring = compute_discovery_ring(shop)
ids = [str(p.id) for p in products]
self.assertEqual(len(ring), 10)
self.assertEqual(set(ring), set(ids))
# No duplicates
self.assertEqual(len(ring), len(set(ring)))
def test_private_and_unlisted_excluded(self):
"""Only visibility==1 products appear in the ring."""
from ..models.shop import compute_discovery_ring
shop = self._make_shop()
public = self._make_product("Public Song", "Visible", 300, visibility=1)
private = self._make_product("Private Song", "Hidden", 200, visibility=0)
unlisted = self._make_product("Unlisted Song", "Unlisted", 100, visibility=2)
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
mp.return_value = [public, private, unlisted]
ring = compute_discovery_ring(shop)
self.assertEqual(ring, [str(public.id)])
self.assertNotIn(str(private.id), ring)
self.assertNotIn(str(unlisted.id), ring)
def test_starts_with_newest(self):
"""Newest product (highest created_timestamp) is at position 0."""
from ..models.shop import compute_discovery_ring
shop = self._make_shop()
old = self._make_product("Old Song", "First release", 100)
mid = self._make_product("Mid Song", "Second release", 200)
new = self._make_product("New Song", "Latest release", 300)
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
mp.return_value = [old, mid, new]
ring = compute_discovery_ring(shop)
self.assertEqual(ring[0], str(new.id))
def test_similar_products_adjacent(self):
"""Guitar cluster should appear before unrelated piano cluster."""
from ..models.shop import compute_discovery_ring
shop = self._make_shop()
g1 = self._make_product("Blues Guitar Jam", "Electric guitar blues riffs", 400)
g2 = self._make_product("Guitar Solo Practice", "Learn guitar solo techniques", 300)
g3 = self._make_product("Acoustic Guitar Chords", "Guitar chord progressions", 200)
piano = self._make_product("Piano Sonata", "Classical piano performance", 100)
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
mp.return_value = [g1, g2, g3, piano]
ring = compute_discovery_ring(shop)
# All guitar items should cluster together before piano
guitar_ids = {str(g1.id), str(g2.id), str(g3.id)}
guitar_positions = [i for i, rid in enumerate(ring) if rid in guitar_ids]
piano_pos = ring.index(str(piano.id))
# Guitar items should be contiguous (adjacent)
self.assertEqual(max(guitar_positions) - min(guitar_positions), 2)
# Piano should be after all guitar items
self.assertGreater(piano_pos, max(guitar_positions))
def test_deterministic(self):
"""Same input produces same output every time."""
from ..models.shop import compute_discovery_ring
shop = self._make_shop()
products = [self._make_product(f"Song {i}", f"Description {i}", 100 + i) for i in range(8)]
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
mp.return_value = products
ring1 = compute_discovery_ring(shop)
ring2 = compute_discovery_ring(shop)
self.assertEqual(ring1, ring2)
def test_ring_is_list_of_strings(self):
"""Ring elements are UUID strings, not UUID objects."""
from ..models.shop import compute_discovery_ring
shop = self._make_shop()
p = self._make_product("Test Song", "Some description", 100)
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
mp.return_value = [p]
ring = compute_discovery_ring(shop)
self.assertIsInstance(ring, list)
for item in ring:
self.assertIsInstance(item, str)
def test_reforge_sets_shop_attribute(self):
"""reforge_discovery_ring stores the ring on the shop object."""
from ..models.shop import reforge_discovery_ring
shop = self._make_shop()
p1 = self._make_product("Song A", "Alpha", 200)
p2 = self._make_product("Song B", "Beta", 100)
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
mp.return_value = [p1, p2]
ring = reforge_discovery_ring(shop)
self.assertEqual(shop.discovery_ring, ring)
self.assertEqual(len(ring), 2)
# Verify JSON column was set
import json
self.assertEqual(json.loads(shop.json_discovery_ring), ring)
class TestShopSubscription(unittest.TestCase):
"""Test ShopSubscription model."""

View file

@ -74,8 +74,13 @@ def content(request):
related_products = []
if product.shop.watch_mode_enabled:
from ..models.product import get_related_products
related_products = get_related_products(product)
ring = product.shop.discovery_ring
if ring:
from ..models.product import get_ring_related_products
related_products = get_ring_related_products(product, ring)
else:
from ..models.product import get_related_products
related_products = get_related_products(product)
return {
"product": product,

View file

@ -9,6 +9,7 @@ from . import (
)
from ..models.product import Product
from ..models.shop import reforge_discovery_ring
from ..models.shop_location import ShopLocation
from ..models.inventory import Inventory
@ -93,8 +94,13 @@ def product(request):
related_products = []
if product.shop.watch_mode_enabled:
from ..models.product import get_related_products
related_products = get_related_products(product)
ring = product.shop.discovery_ring
if ring:
from ..models.product import get_ring_related_products
related_products = get_ring_related_products(product, ring)
else:
from ..models.product import get_related_products
related_products = get_related_products(product)
return {
"product": product,
@ -163,6 +169,9 @@ def product_new(request):
request.dbsession.add(product)
request.dbsession.flush()
if request.shop.watch_mode_enabled:
reforge_discovery_ring(request.shop)
request.dbsession.flush()
msg = ("Great, next you may upload files.", "success")
request.session.flash(msg)
return HTTPFound(f"/p/{product.id}/edit")
@ -198,6 +207,9 @@ def product_edit_description(request):
request.session.flash(msg)
request.dbsession.add(product)
request.dbsession.flush()
if product.shop.watch_mode_enabled:
reforge_discovery_ring(product.shop)
request.dbsession.flush()
return {
"markup_subject": f"Product Description for {product.title}",
@ -330,6 +342,9 @@ def product_edit(request):
product.stamp_updated_timestamp()
request.dbsession.add(product)
request.dbsession.flush()
if product.shop.watch_mode_enabled:
reforge_discovery_ring(product.shop)
request.dbsession.flush()
# redirect back to this page to clear
# the params posted by the s3 webhooks.
@ -338,6 +353,9 @@ def product_edit(request):
if product_modified and product.error_message is None:
request.dbsession.add(product)
request.dbsession.flush()
if product.shop.watch_mode_enabled:
reforge_discovery_ring(product.shop)
request.dbsession.flush()
signed_posts = {}

View file

@ -1,6 +1,7 @@
from pyramid.view import view_config
from ..models.product import get_media_type, get_related_products
from ..models.product import get_media_type, get_related_products, get_ring_related_products
from ..models.shop import reforge_discovery_ring
@view_config(route_name="watch_json", renderer="json")
@ -75,8 +76,17 @@ def watch_json(request):
p_or_c = "p" if product.is_sellable else "c"
canonical_url = f"/{p_or_c}/{product.id}/{product.slug}"
# Related products
related = get_related_products(product)
# Related products via discovery ring (fallback to stem-based)
ring = request.shop.discovery_ring
if ring:
if str(product.id) not in ring:
# Product missing from ring — reforge inline
ring = reforge_discovery_ring(request.shop)
request.dbsession.flush()
related = get_ring_related_products(product, ring)
else:
related = get_related_products(product)
related_data = []
for r in related:
r_ext = r.extensions.get("product", "")
@ -107,4 +117,5 @@ def watch_json(request):
"canonical_url": canonical_url,
"is_sellable": product.is_sellable,
"related": related_data,
"ring": ring,
}