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%
5.6 KB · 109 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { ActionButton, AdminFilters, AdminTitle, Mono, Notice } from '@/components/admin/ui';4import { EntityBadge } from '@/components/ui/badges';5import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';6import { Unavailable } from '@/components/ui/unavailable';7import { mergeAction } from '@/lib/admin/actions';8import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';9import type { DuplicateSide } from '@/lib/admin/types';10import { fmtDate, fmtInt, fmtPct, num } from '@/lib/format';11import { routes } from '@/lib/site';1213export const metadata: Metadata = { title: 'Duplicates', robots: { index: false, follow: false } };14export const dynamic = 'force-dynamic';1516const TYPES = ['model', 'company', 'organization', 'lab', 'paper', 'provider', 'benchmark', 'hardware', 'framework', 'library', 'dataset', 'tool', 'repository', 'researcher'];1718function Side({ s, type }: { s: DuplicateSide; type: string }) {19  return (20    <div className="min-w-0">21      <Link href={routes.entity({ entity_type: type, slug: s.slug })} className="block truncate text-sm font-medium text-ink hover:text-accent" title={s.name}>22        {s.name}23      </Link>24      <p className="truncate text-[11px] text-ink-3">25        <Mono>{s.slug}</Mono>26        {s.organization && <span> · {s.organization}</span>}27      </p>28      <p className="tnum text-[11px] text-ink-3">29        {fmtInt(s.claims)} claims · first seen {fmtDate(s.first_seen_at)}30      </p>31    </div>32  );33}3435export default async function AdminDuplicatesPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {36  await requireAdmin();37  const sp = await searchParams;38  const type = sp.type ?? 'model';39  const ret = `/admin/entities/duplicates?type=${encodeURIComponent(type)}`;40  const res = await load(adminApi.duplicates({ type, limit: 200 }));41  return (42    <>43      <AdminTitle title="Duplicate candidates" count={res.ok ? fmtInt(res.data.items.length) : undefined} lede={res.ok ? `pg_trgm similarity ≥ ${fmtPct((num(res.data.threshold) ?? 0) * 100, 0)} on normalized names, within one type. Merging moves aliases, identifiers, claims, relations and events into the target and marks the source merged_into.` : undefined} />44      <Notice notice={sp.notice} level={sp.level} />45      <AdminFilters action="/admin/entities/duplicates" className="mb-4" fields={[{ kind: 'select', name: 'type', label: 'Type', value: type, any: 'model', options: TYPES.map((t) => ({ value: t, label: t })) }]} />46      {!res.ok ? (47        <Unavailable what="Duplicates" reason={res.error} />48      ) : (49        <DataTable compact caption="Duplicate candidates">50          <thead>51            <tr>52              <Th>A</Th>53              <Th>B</Th>54              <Th num>Similarity</Th>55              <Th>Merge</Th>56            </tr>57          </thead>58          <tbody>59            {res.data.items.length === 0 && <EmptyRow cols={4}>No candidate pairs for this type.</EmptyRow>}60            {res.data.items.map((d) => {61              const aClaims = num(d.a.claims) ?? 0;62              const bClaims = num(d.b.claims) ?? 0;63              // Default suggestion: the record with fewer claims (or the newer one) is the source, the richer one the target.64              const preferAB = aClaims < bClaims || (aClaims === bClaims && (d.a.first_seen_at ?? '') > (d.b.first_seen_at ?? ''));65              return (66                <tr key={`${d.a.id}-${d.b.id}`}>67                  <Td label="A" primary>68                    <div className="flex items-start gap-2">69                      <EntityBadge type={d.entity_type} small className="mt-0.5" />70                      <Side s={d.a} type={d.entity_type} />71                    </div>72                  </Td>73                  <Td label="B" primary>74                    <div className="flex items-start gap-2">75                      <EntityBadge type={d.entity_type} small className="mt-0.5" />76                      <Side s={d.b} type={d.entity_type} />77                    </div>78                  </Td>79                  <Td num label="Similarity" className="tnum text-xs">{fmtPct((num(d.similarity) ?? 0) * 100, 0)}</Td>80                  <Td label="Merge" wide>81                    <div className="flex flex-wrap items-center gap-1.5">82                      <form action={mergeAction}>83                        <input type="hidden" name="source_id" value={d.a.id} />84                        <input type="hidden" name="target_id" value={d.b.id} />85                        <input type="hidden" name="return" value={ret} />86                        <ActionButton tone={preferAB ? 'accent' : 'neutral'} title={`Merge ${d.a.slug} into ${d.b.slug} (A disappears, B keeps everything)`}>87                          Merge A → B{preferAB ? ' · suggested' : ''}88                        </ActionButton>89                      </form>90                      <form action={mergeAction}>91                        <input type="hidden" name="source_id" value={d.b.id} />92                        <input type="hidden" name="target_id" value={d.a.id} />93                        <input type="hidden" name="return" value={ret} />94                        <ActionButton tone={preferAB ? 'neutral' : 'accent'} title={`Merge ${d.b.slug} into ${d.a.slug} (B disappears, A keeps everything)`}>95                          Merge B → A{preferAB ? '' : ' · suggested'}96                        </ActionButton>97                      </form>98                    </div>99                  </Td>100                </tr>101              );102            })}103          </tbody>104        </DataTable>105      )}106    </>107  );108}109