HTML 77.2%
TypeScript 10.5%
Python 9.6%
JavaScript 2.5%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { ResolutionPair } from '@/components/admin/resolution-pair';4import { AdminFilters, AdminTitle, Mono, Notice } from '@/components/admin/ui';5import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';6import { Pagination, withParams } from '@/components/ui/pagination';7import { Note } from '@/components/ui/section';8import { EmptyState, Unavailable } from '@/components/ui/unavailable';9import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';10import { fmtDateTime, fmtInt, fmtPct, num } from '@/lib/format';11import { routes } from '@/lib/site';1213export const metadata: Metadata = { title: 'Entity resolution', robots: { index: false, follow: false } };14export const dynamic = 'force-dynamic';1516const LIMIT = 20;17const TYPES = ['model', 'company', 'organization', 'lab', 'paper', 'benchmark', 'provider', 'hardware', 'framework', 'dataset'];1819export default async function EntityResolutionPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {20 await requireAdmin();21 const sp = await searchParams;22 const current: Record<string, string | undefined> = { type: sp.type ?? 'model', status: sp.status ?? 'pending' };23 if (sp.threshold) current.threshold = sp.threshold;24 if (sp.offset) current.offset = sp.offset;25 const offset = Math.max(0, Number(current.offset) || 0);26 const href = (patch: Record<string, string | number | undefined | null>) => withParams('/admin/entity-resolution', current, patch);27 const ret = href({});28 const [res, decided] = await Promise.all([load(adminApi.entityResolution({ type: current.type, status: current.status, threshold: current.threshold, limit: LIMIT, offset })), current.status === 'decided' ? Promise.resolve(null) : load(adminApi.entityResolution({ type: current.type, status: 'decided', limit: 10 }))]);29 return (30 <>31 <AdminTitle title="Entity resolution" count={res.ok ? fmtInt(res.data.total) : undefined} lede="Candidate pairs side by side. Nothing is merged until you decide; decisions persist (resolution_decisions) and are applied by the API: merge, alias, variant-of, family member — or keep separate / defer.">32 <Link href="/admin/quality" className="text-xs text-ink-3 hover:text-ink">33 Data quality →34 </Link>35 </AdminTitle>36 <Notice notice={sp.notice} level={sp.level} />37 <AdminFilters38 action="/admin/entity-resolution"39 className="mb-4"40 fields={[41 { kind: 'select', name: 'type', label: 'Entity type', value: current.type, any: 'model', options: TYPES.map((t) => ({ value: t, label: t })) },42 { kind: 'select', name: 'status', label: 'Status', value: current.status, any: 'pending', options: ['pending', 'decided', 'all'].map((s) => ({ value: s, label: s })) },43 { kind: 'select', name: 'threshold', label: 'Similarity ≥', value: current.threshold, any: '0.8 (default)', options: ['0.6', '0.7', '0.8', '0.9', '0.95'].map((v) => ({ value: v, label: v })) },44 ]}45 />46 {!res.ok ? (47 <Unavailable what="Entity resolution" reason={res.error} />48 ) : res.data.items.length === 0 ? (49 <EmptyState title="No candidate pairs">{current.status === 'pending' ? 'Nothing above the similarity threshold awaits a decision for this type.' : 'No decided pairs for this type yet.'}</EmptyState>50 ) : (51 <>52 <p className="mb-3 text-xs text-ink-3">53 Threshold {fmtPct((num(res.data.threshold) ?? 0) * 100, 0)} · signals: review queue, trigram similarity, shared variant key. {res.data.note}54 </p>55 <ul className="space-y-6" data-resolution-list>56 {res.data.items.map((it) => (57 <li key={`${it.a.id}-${it.b.id}`}>58 <ResolutionPair item={it} decisions={res.data.decisions} returnTo={ret} />59 </li>60 ))}61 </ul>62 <Pagination total={res.data.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" />63 </>64 )}65 {decided && decided.ok && (66 <section className="mt-10">67 <h2 className="text-base font-semibold tracking-tight">68 Decision history <span className="tnum text-sm font-normal text-ink-3">{fmtInt(decided.data.total)}</span>69 </h2>70 <Note className="mb-3 mt-1">Latest decided pairs for this type (persisted in resolution_decisions; every POST is also in the audit log).</Note>71 <DataTable compact scroll caption="Decision history">72 <thead>73 <tr>74 <Th>A</Th>75 <Th>B</Th>76 <Th>Decision</Th>77 <Th>Note</Th>78 <Th>Decided</Th>79 </tr>80 </thead>81 <tbody>82 {decided.data.items.length === 0 && <EmptyRow cols={5}>No decision recorded yet.</EmptyRow>}83 {decided.data.items.map((it) => {84 const d = (typeof it.decision === 'string' ? { decision: it.decision } : it.decision) as { decision: string; note?: string | null; decided_at?: string | null; applied?: boolean } | null;85 return (86 <tr key={`${it.a.id}-${it.b.id}`}>87 <Td primary>88 <Link href={routes.entity(it.a)} className="text-ink hover:text-accent">89 {it.a.name}90 </Link>{' '}91 <Mono>{it.a.slug}</Mono>92 </Td>93 <Td>94 <Link href={routes.entity(it.b)} className="text-ink hover:text-accent">95 {it.b.name}96 </Link>{' '}97 <Mono>{it.b.slug}</Mono>98 </Td>99 <Td>100 <Mono>{d?.decision ?? '—'}</Mono>101 {d && 'applied' in d && d.applied !== undefined && <span className="ml-1 text-[11px] text-ink-3">{d.applied ? 'applied' : 'recorded'}</span>}102 </Td>103 <Td className="text-xs text-ink-2">{d && typeof d.note === 'string' ? d.note : '—'}</Td>104 <Td className="tnum text-xs text-ink-3">{d && typeof d.decided_at === 'string' ? fmtDateTime(d.decided_at) : '—'}</Td>105 </tr>106 );107 })}108 </tbody>109 </DataTable>110 <p className="mt-2 text-xs">111 <Link href={href({ status: 'decided', offset: undefined })} className="link">112 All decided pairs →113 </Link>{' '}114 · <Link href="/admin/audit?action=entity-resolution" className="link">Audit log →</Link>115 </p>116 </section>117 )}118 </>119 );120}121