299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
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}
|
|
</>
|
|
);
|
|
}
|