0.1.38 gateway
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user