Files
memby/admin-ui/src/pages/Inspector.tsx
T

299 lines
11 KiB
TypeScript
Raw Normal View History

2026-08-14 09:40:03 +12:00
import { useState } from 'react';
import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast';
import { num, when } from '../lib/format';
import { Banner, Button, Card, Chip, Empty, Field, Grid, PageHead, Tiles } from '../components/ui';
/* Nothing here changes anything. It re-runs the shared weighted scorer over one person's
prepared pool, after Emby permission and parental-control filtering, and shows every
component and evidence reason behind the order. */
interface Weighted {
weight?: number;
evidence?: number;
}
interface Profile {
genres?: Record<string, Weighted>;
studios?: Record<string, Weighted>;
actors?: Record<string, Weighted>;
directors?: Record<string, Weighted>;
franchises?: Record<string, Weighted>;
runtimeRanges?: Record<string, Weighted>;
ageRatings?: Record<string, Weighted>;
communityRatings?: Record<string, Weighted>;
releasePeriods?: Record<string, Weighted>;
contentTypes?: Record<string, Weighted>;
}
interface InspectorItem {
title: string;
type?: string;
year?: number;
runtimeMinutes?: number;
genres?: string[];
baseRank?: number;
baseScore?: number;
affinityScore?: number;
compatibilityScore?: number;
compatibilityLabel?: string;
preparedReason?: string;
preparedEvidenceTitle?: string;
eligibleRows?: string[];
exposure?: { impressions?: number; focuses?: number; selects?: number };
explanation?: { total?: number; components?: Record<string, number>; reasonCodes?: string[] };
}
interface InspectorResponse {
poolCandidates: number;
permissionEligible: number;
items: InspectorItem[] | null;
profile: Profile | null;
actions: { action: string; title?: string; itemId?: string }[] | null;
profileMeta?: { sourceEvents?: number; algorithmVersion?: string; poolBuiltAt?: string };
}
const DIMENSIONS: [string, keyof Profile][] = [
['Genre', 'genres'],
['Studio', 'studios'],
['Actor', 'actors'],
['Director', 'directors'],
['Franchise', 'franchises'],
['Runtime', 'runtimeRanges'],
['Age rating', 'ageRatings'],
['Community rating', 'communityRatings'],
['Release period', 'releasePeriods'],
['Content type', 'contentTypes'],
];
function affinities(profile: Profile | null) {
if (!profile) return [];
return DIMENSIONS.flatMap(([dimension, key]) =>
Object.entries(profile[key] ?? {}).map(([name, value]) => ({
dimension,
name,
weight: value.weight ?? 0,
evidence: value.evidence ?? 0,
})),
).sort((a, b) => Math.abs(b.weight) - Math.abs(a.weight));
}
const signed = (value: number) => `${value >= 0 ? '+' : ''}${value.toFixed(3)}`;
export function InspectorPage() {
const { status } = useGateway();
const { wrap } = useToast();
const { busy, run } = useAction();
const [userId, setUserId] = useState('');
const [context, setContext] = useState('default');
const [minutes, setMinutes] = useState('0');
const [at, setAt] = useState('');
const [result, setResult] = useState<InspectorResponse | null>(null);
const [hint, setHint] = useState('Choose a person to inspect their recommendations.');
const [error, setError] = useState('');
const users = status?.requestUsers ?? [];
const runTest = () =>
run('run', async () => {
if (!userId) {
setError('Choose a person to pressure-test.');
return;
}
setError('');
setHint('Running the permission check and the scorer…');
const params = new URLSearchParams({ userId, context, minutes: minutes || '0', limit: '100' });
if (at) params.set('at', new Date(at).toISOString());
const payload = await wrap(() =>
api.get<InspectorResponse>(`/admin/api/recommendations?${params.toString()}`),
);
if (payload) {
setResult(payload);
setHint(`Scored at ${new Date().toLocaleTimeString()}.`);
} else {
setHint('Pressure test failed.');
}
});
const top = affinities(result?.profile ?? null).slice(0, 24);
const actions = result?.actions ?? [];
const items = result?.items ?? [];
const meta = result?.profileMeta ?? {};
return (
<>
<PageHead
title="Score inspector"
intro="Re-run the ranker for one person and read every component."
/>
<Banner message={error} />
<Card
title="Run a pressure test"
intro="Nothing is changed by running this. It scores the person's prepared pool as the launcher would, in the context you choose."
icon="search"
tone="info"
footer={
<>
<Button variant="primary" busy={busy === 'run'} onClick={() => void runTest()}>
Run pressure test
</Button>
<span className="hint">{hint}</span>
</>
}
>
<div className="fields">
<Field label="Person">
<select value={userId} onChange={(event) => setUserId(event.target.value)}>
<option value="">Choose a person</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.username}
</option>
))}
</select>
</Field>
<Field label="Context">
<select value={context} onChange={(event) => setContext(event.target.value)}>
<option value="default">Default</option>
<option value="bedtime">One episode before bed</option>
<option value="hidden">Hidden library</option>
<option value="new-releases">Recent new releases</option>
</select>
</Field>
<Field label="Available minutes">
<input
type="number"
min={0}
max={360}
value={minutes}
onChange={(event) => setMinutes(event.target.value)}
/>
</Field>
<Field label="Evaluate at">
<input type="datetime-local" value={at} onChange={(event) => setAt(event.target.value)} />
</Field>
</div>
</Card>
{result ? (
<>
<Tiles
tiles={[
{ label: 'prepared pool', value: num(result.poolCandidates), icon: 'database', tone: 'data' },
{ label: 'permission eligible', value: num(result.permissionEligible), icon: 'shield', tone: 'ok' },
{ label: 'ranked result', value: num(items.length), icon: 'sparkle', tone: 'note' },
{ label: 'source events', value: num(meta.sourceEvents ?? 0), icon: 'pulse', tone: 'info' },
{ label: 'algorithm', value: meta.algorithmVersion || '—', small: true, icon: 'chip' },
{ label: 'pool built', value: when(meta.poolBuiltAt), small: true, icon: 'clock' },
]}
/>
<Card
title="Profile evidence"
intro="The strongest learned affinities, and every explicit action this person has taken."
icon="sparkle"
tone="note"
>
{top.length === 0 ? (
<Empty>No repeated affinity evidence yet; cold-start priors apply.</Empty>
) : (
<div className="chips">
{top.map((entry) => (
<Chip key={`${entry.dimension}:${entry.name}`} tone={entry.weight < 0 ? 'bad' : undefined}>
{entry.dimension}: {entry.name} {signed(entry.weight)} · n={num(entry.evidence)}
</Chip>
))}
</div>
)}
{actions.length === 0 ? (
<Empty>No explicit recommendation actions.</Empty>
) : (
<div className="chips">
{actions.map((action, index) => (
<Chip key={`${action.action}:${index}`} tone="ok">
{action.action}: {action.title || action.itemId}
</Chip>
))}
</div>
)}
</Card>
{items.length === 0 ? (
<Card>
<Empty>
No candidates survived this context, the explicit exclusions and the permission filter.
</Empty>
</Card>
) : (
<Grid>
{items.map((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,
...(item.genres ?? []),
]
.filter(Boolean)
.join(' · ');
return (
<Card
key={`${index}:${item.title}`}
title={`#${index + 1} · ${item.title}`}
intro={facts}
actions={<Chip tone="ok">{Number(explanation.total ?? 0).toFixed(3)}</Chip>}
>
<p className="hint">
{item.preparedReason || 'No legacy prepared explanation'}
{item.compatibilityLabel ? ` · ${item.compatibilityLabel}` : ''}
</p>
<div className="chips">
{(explanation.reasonCodes ?? []).map((code) => (
<Chip key={code} tone="ok">
{code}
</Chip>
))}
{components.map(([name, value]) => (
<Chip key={name} tone={value < 0 ? 'bad' : undefined}>
{name}={signed(value)}
</Chip>
))}
</div>
<details>
<summary className="muted">Pool, row and exposure detail</summary>
<p className="hint">
Base rank {num(item.baseRank ?? 0)} · base {Number(item.baseScore ?? 0).toFixed(3)} ·
affinity {Number(item.affinityScore ?? 0).toFixed(3)} · compatibility{' '}
{Number(item.compatibilityScore ?? 0).toFixed(3)} · impressions{' '}
{num(exposure.impressions ?? 0)} · focuses {num(exposure.focuses ?? 0)} · selects{' '}
{num(exposure.selects ?? 0)}
</p>
<div className="chips">
{(item.eligibleRows ?? []).map((row) => (
<Chip key={row} tone="ok">
{row}
</Chip>
))}
</div>
{item.preparedEvidenceTitle ? (
<p className="hint">Prepared evidence: {item.preparedEvidenceTitle}</p>
) : null}
</details>
</Card>
);
})}
</Grid>
)}
</>
) : null}
</>
);
}