Files
memby/server/internal/api/admin/pages/inspector.js
T

124 lines
5.8 KiB
JavaScript
Raw Normal View History

2026-08-06 22:33:56 +12:00
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));