124 lines
5.8 KiB
JavaScript
124 lines
5.8 KiB
JavaScript
const { fmt, ui, $ } = Admin;
|
|||
|
|
|
||
|
|
// The user list rides along on the ordinary status poll; the inspection itself is only ever
|
||
|
|
// run on request, because it re-scores a whole pool.
|
||
|
|
Admin.onStatus((status) => {
|
||
|
|
const select = $('inspector-user');
|
||
|
|
if (!Admin.settled(select.parentElement)) return;
|
||
|
|
const chosen = select.value;
|
||
|
|
select.innerHTML = '<option value="">Choose a person…</option>' +
|
||
|
|
(status.requestUsers || []).map((user) => '<option value="' + fmt.escape(user.id) + '">' +
|
||
|
|
fmt.escape(user.username) + '</option>').join('');
|
||
|
|
select.value = chosen;
|
||
|
|
});
|
||
|
|
|
||
|
|
function affinities(profile) {
|
||
|
|
return [
|
||
|
|
['Genre', profile.genres], ['Studio', profile.studios], ['Actor', profile.actors],
|
||
|
|
['Director', profile.directors], ['Franchise', profile.franchises],
|
||
|
|
['Runtime', profile.runtimeRanges], ['Age rating', profile.ageRatings],
|
||
|
|
['Community rating', profile.communityRatings], ['Release period', profile.releasePeriods],
|
||
|
|
['Content type', profile.contentTypes],
|
||
|
|
].flatMap(([dimension, values]) => Object.entries(values || {}).map(([name, value]) => ({
|
||
|
|
dimension, name, weight: value.weight || 0, evidence: value.evidence || 0,
|
||
|
|
}))).sort((a, b) => Math.abs(b.weight) - Math.abs(a.weight));
|
||
|
|
}
|
||
|
|
|
||
|
|
function component(name, value) {
|
||
|
|
return ui.chip(name + '=' + (value >= 0 ? '+' : '') + Number(value).toFixed(3),
|
||
|
|
value < 0 ? 'bad' : undefined);
|
||
|
|
}
|
||
|
|
|
||
|
|
function resultCard(item, index) {
|
||
|
|
const explanation = item.explanation || {};
|
||
|
|
const components = Object.entries(explanation.components || {})
|
||
|
|
.sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]));
|
||
|
|
const exposure = item.exposure || {};
|
||
|
|
const facts = [item.type, item.year, item.runtimeMinutes ? item.runtimeMinutes + ' min' : null]
|
||
|
|
.concat(item.genres || []).filter(Boolean).join(' · ');
|
||
|
|
|
||
|
|
return '<section class="card"><div class="card-head split"><div>' +
|
||
|
|
'<h2 class="card-title">#' + (index + 1) + ' · ' + fmt.escape(item.title) + '</h2>' +
|
||
|
|
'<p class="card-note">' + fmt.escape(facts) + '</p></div>' +
|
||
|
|
'<span class="score">' + Number(explanation.total || 0).toFixed(3) + '</span></div>' +
|
||
|
|
'<p class="hint">' + fmt.escape(item.preparedReason || 'No legacy prepared explanation') +
|
||
|
|
(item.compatibilityLabel ? ' · ' + fmt.escape(item.compatibilityLabel) : '') + '</p>' +
|
||
|
|
'<div class="chips">' +
|
||
|
|
(explanation.reasonCodes || []).map((code) => ui.chip(code, 'accent')).join('') +
|
||
|
|
components.map(([name, value]) => component(name, value)).join('') + '</div>' +
|
||
|
|
'<details><summary>Pool, row and exposure detail</summary><p class="hint">' +
|
||
|
|
'Base rank ' + fmt.number(item.baseRank) +
|
||
|
|
' · base ' + Number(item.baseScore || 0).toFixed(3) +
|
||
|
|
' · affinity ' + Number(item.affinityScore || 0).toFixed(3) +
|
||
|
|
' · compatibility ' + Number(item.compatibilityScore || 0).toFixed(3) +
|
||
|
|
' · impressions ' + fmt.number(exposure.impressions) +
|
||
|
|
' · focuses ' + fmt.number(exposure.focuses) +
|
||
|
|
' · selects ' + fmt.number(exposure.selects) + '</p>' +
|
||
|
|
'<div class="chips">' + (item.eligibleRows || [])
|
||
|
|
.map((row) => ui.chip(row, 'accent')).join('') + '</div>' +
|
||
|
|
(item.preparedEvidenceTitle
|
||
|
|
? '<p class="hint">Prepared evidence: ' + fmt.escape(item.preparedEvidenceTitle) + '</p>'
|
||
|
|
: '') +
|
||
|
|
'</details></section>';
|
||
|
|
}
|
||
|
|
|
||
|
|
function render(payload) {
|
||
|
|
const meta = payload.profileMeta || {};
|
||
|
|
const tiles = $('inspector-tiles');
|
||
|
|
tiles.hidden = false;
|
||
|
|
tiles.innerHTML = ui.tiles([
|
||
|
|
['prepared pool', fmt.number(payload.poolCandidates), { icon: 'database', tone: 'data' }],
|
||
|
|
['permission eligible', fmt.number(payload.permissionEligible), { icon: 'shield', tone: 'ok' }],
|
||
|
|
['ranked result', fmt.number((payload.items || []).length), { icon: 'sparkle', tone: 'note' }],
|
||
|
|
['source events', fmt.number(meta.sourceEvents), { icon: 'pulse', tone: 'info' }],
|
||
|
|
['algorithm', meta.algorithmVersion || '—', { small: true, icon: 'chip' }],
|
||
|
|
['pool built', fmt.when(meta.poolBuiltAt), { small: true, icon: 'clock' }],
|
||
|
|
]);
|
||
|
|
|
||
|
|
const top = affinities(payload.profile || {}).slice(0, 24);
|
||
|
|
const actions = payload.actions || [];
|
||
|
|
$('inspector-profile-card').hidden = false;
|
||
|
|
$('inspector-profile').innerHTML =
|
||
|
|
'<div class="chips">' + (top.length
|
||
|
|
? top.map((entry) => ui.chip(entry.dimension + ': ' + entry.name + ' ' +
|
||
|
|
(entry.weight >= 0 ? '+' : '') + entry.weight.toFixed(3) +
|
||
|
|
' · n=' + fmt.number(entry.evidence), entry.weight < 0 ? 'bad' : undefined)).join('')
|
||
|
|
: ui.empty('No repeated affinity evidence yet; cold-start priors apply.')) + '</div>' +
|
||
|
|
'<div class="chips">' + (actions.length
|
||
|
|
? actions.map((action) => ui.chip(action.action + ': ' +
|
||
|
|
(action.title || action.itemId), 'accent')).join('')
|
||
|
|
: ui.empty('No explicit recommendation actions.')) + '</div>';
|
||
|
|
|
||
|
|
const items = payload.items || [];
|
||
|
|
$('inspector-results').innerHTML = items.length
|
||
|
|
? items.map(resultCard).join('')
|
||
|
|
: ui.empty('No candidates survived this context, the explicit exclusions and the permission filter.');
|
||
|
|
}
|
||
|
|
|
||
|
|
async function run() {
|
||
|
|
const userId = $('inspector-user').value;
|
||
|
|
if (!userId) {
|
||
|
|
Admin.error('Choose a person to pressure-test.');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const params = new URLSearchParams({
|
||
|
|
userId,
|
||
|
|
context: $('inspector-context').value,
|
||
|
|
minutes: $('inspector-minutes').value || '0',
|
||
|
|
limit: '100',
|
||
|
|
});
|
||
|
|
const at = $('inspector-at').value;
|
||
|
|
if (at) params.set('at', new Date(at).toISOString());
|
||
|
|
$('inspector-hint').textContent = 'Running the permission check and the scorer…';
|
||
|
|
try {
|
||
|
|
render(await Admin.api('/admin/api/recommendations?' + params.toString()));
|
||
|
|
$('inspector-hint').textContent = 'Scored at ' + new Date().toLocaleTimeString() + '.';
|
||
|
|
Admin.error('');
|
||
|
|
} catch (err) {
|
||
|
|
$('inspector-hint').textContent = 'Pressure test failed.';
|
||
|
|
Admin.error(err.message);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Admin.ready(() => $('inspector-run').addEventListener('click', run));
|