SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
6.7 KB · 121 lines tsx
Raw Blame History
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