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%
10.4 KB · 229 lines tsx
Raw Blame History
1'use client';2import { ExternalLink, History, TriangleAlert } from 'lucide-react';3import Link from 'next/link';4import { useEffect, useState } from 'react';5import { ConfidenceBadge, TierBadge } from '@/components/ui/badges';6import { Sheet } from '@/components/ui/sheet';7import { ClientApiError, clientApi } from '@/lib/client-api';8import { cn } from '@/lib/cn';9import { DASH, fmtAgo, fmtDateTime, fmtValue, hostOf } from '@/lib/format';10import { propertyLabel, routes, TIER_LABELS } from '@/lib/site';11import type { Claim, ProvenanceDetail } from '@/lib/types';12import { type EvidenceTarget, useEvidence } from './evidence-context';1314type State = { status: 'loading' | 'ready' | 'none'; detail: ProvenanceDetail | null; source: 'api' | 'history' | 'fallback' | null };1516/**17 * Right-side panel (≥ lg) / bottom sheet (< lg) explaining one displayed value: VALUE · SOURCE · TIER · OBSERVED · EXTRACTOR ·18 * CONFIDENCE · VALID SINCE · links (View source · View history · View conflicts · Claim id).19 * Data: `GET /entities/{slug}/provenance/{property}` (1.1) → else the property's claim history (exists today) → else the20 * inline `fallback` the trigger passed. Mount once in the root layout, inside `EvidenceProvider`.21 */22export function EvidenceDrawer() {23  const { target, close } = useEvidence();24  const [state, setState] = useState<State>({ status: 'none', detail: null, source: null });2526  useEffect(() => {27    if (!target) return;28    const ctrl = new AbortController();29    setState({ status: 'loading', detail: fromFallback(target), source: target.fallback ? 'fallback' : null });30    (async () => {31      try {32        const d = await clientApi.provenance(target.slug, target.property, ctrl.signal);33        setState({ status: 'ready', detail: { ...fromFallback(target), ...d, property: d.property ?? target.property }, source: 'api' });34        return;35      } catch (e) {36        if ((e as Error).name === 'AbortError') return;37        if (!(e instanceof ClientApiError) || (e.status !== 404 && e.status !== 405)) {38          setState((s) => ({ ...s, status: 'ready' }));39          return;40        }41      }42      try {43        const h = await clientApi.history(target.slug, target.property, ctrl.signal);44        setState({ status: 'ready', detail: fromHistory(target, h.items ?? []), source: 'history' });45      } catch (e) {46        if ((e as Error).name === 'AbortError') return;47        setState((s) => ({ ...s, status: 'ready' }));48      }49    })();50    return () => ctrl.abort();51  }, [target]);5253  const d = state.detail;54  const open = target !== null;55  const entityRef = target ? { entity_type: target.entity?.entity_type ?? 'model', slug: target.slug } : null;56  const conflicts = d?.conflicts ?? [];57  const sourceLabel = d?.source_name ?? d?.domain ?? hostOf(d?.url) ?? null;5859  return (60    <Sheet open={open} onClose={close} eyebrow="Evidence" title={target ? `${propertyLabel(target.property)}${target.entity?.name ? ` · ${target.entity.name}` : ''}` : undefined} labelledBy="evidence-title">61      {target && (62        <div className="space-y-4 text-sm" data-evidence-drawer>63          <div>64            <p className="eyebrow">Value</p>65            <p className={cn('mt-1 text-[22px] font-semibold leading-tight tracking-tight text-ink', typeof (d?.value ?? target.value) === 'number' && 'tnum')}>66              {target.display ?? fmtValue(d?.value ?? target.value, target.property)}67              {(d?.unit ?? target.unit) && !/tokens|GB|W|\$|%/.test(target.display ?? '') && <span className="ml-1.5 text-sm font-normal text-ink-3">{d?.unit ?? target.unit}</span>}68            </p>69          </div>7071          {state.status === 'loading' && !d && <p className="text-xs text-ink-3">Loading evidence…</p>}72          {state.status === 'ready' && !d && (73            <p className="border border-dashed border-rule-strong px-3 py-3 text-xs text-ink-3" role="status">74              No provenance recorded for this value. Every fact on AI Atlas should carry one — this is a data gap, not a hidden source.75            </p>76          )}7778          {d && (79            <dl className="kv [&>div]:grid-cols-[6.5rem_minmax(0,1fr)]">80              <Row k="Source">81                {d.url ? (82                  <a href={d.url} target="_blank" rel="noopener noreferrer" className="link inline-flex items-center gap-1 break-all">83                    {sourceLabel ?? d.url} <ExternalLink className="size-3 shrink-0" aria-hidden />84                  </a>85                ) : (86                  <span>{sourceLabel ?? DASH}</span>87                )}88                {d.domain && sourceLabel !== d.domain && <span className="mono ml-1.5 text-[11px] text-ink-3">{d.domain}</span>}89              </Row>90              <Row k="Tier">91                <TierBadge tier={d.tier} withLabel /> {d.tier ? <span className="text-xs text-ink-3">{tierHint(d.tier)}</span> : null}92              </Row>93              <Row k="Observed">94                <span title={d.observed_at ?? undefined}>{d.observed_at ? `${fmtAgo(d.observed_at)} · ${fmtDateTime(d.observed_at)}` : DASH}</span>95              </Row>96              <Row k="Extractor">97                <span className="mono text-xs">{d.extractor ?? DASH}</span>98                {d.extractor?.startsWith('llm') && <span className="ml-1.5 text-xs text-ink-3">local LLM factory · claims one tier lower</span>}99              </Row>100              <Row k="Confidence">101                <ConfidenceBadge confidence={d.confidence} /> {!d.confidence && DASH}102              </Row>103              <Row k="Valid since">104                <span title={d.valid_from ?? undefined}>{d.valid_from ? fmtDateTime(d.valid_from) : DASH}</span>105                {d.valid_to && <span className="ml-1.5 text-xs text-warning">until {fmtDateTime(d.valid_to)}</span>}106              </Row>107              {d.status && d.status !== 'current' && (108                <Row k="Status">109                  <span className={cn('text-xs uppercase tracking-wide', d.status === 'conflicting' ? 'text-danger' : 'text-ink-2')}>{d.status}</span>110                </Row>111              )}112            </dl>113          )}114115          {conflicts.length > 0 && (116            <div className="border-l-2 border-danger pl-3">117              <p className="flex items-center gap-1.5 text-xs font-medium text-danger">118                <TriangleAlert className="size-3.5" aria-hidden /> {conflicts.length} conflicting {conflicts.length === 1 ? 'claim' : 'claims'} — stored side by side, never averaged119              </p>120              <ul className="mt-1.5 space-y-1.5">121                {conflicts.slice(0, 5).map((c) => (122                  <li key={c.id} className="text-xs text-ink-2">123                    <span className="tnum font-medium text-ink">{fmtValue(c.value, c.property)}</span> · <TierBadge tier={c.tier} /> ·{' '}124                    {c.source_url ? (125                      <a href={c.source_url} target="_blank" rel="noopener noreferrer" className="link">126                        {c.source_name ?? hostOf(c.source_url)}127                      </a>128                    ) : (129                      c.source_name ?? 'source'130                    )}{' '}131                    · {fmtAgo(c.observed_at)}132                  </li>133                ))}134              </ul>135            </div>136          )}137138          <div className="flex flex-wrap gap-x-4 gap-y-1.5 border-t border-rule pt-3 text-xs">139            {d?.url && (140              <a href={d.url} target="_blank" rel="noopener noreferrer" className="link inline-flex items-center gap-1">141                View source <ExternalLink className="size-3" aria-hidden />142              </a>143            )}144            {entityRef && (145              <Link href={routes.entityHistory(entityRef, target.property)} onClick={close} className="link inline-flex items-center gap-1">146                <History className="size-3" aria-hidden /> View history147              </Link>148            )}149            {conflicts.length > 0 && entityRef && (150              <Link href={routes.entityHistory(entityRef, target.property)} onClick={close} className="link text-danger">151                View conflicts152              </Link>153            )}154            <Link href={routes.methodology()} onClick={close} className="text-ink-3 hover:text-ink">155              Methodology156            </Link>157          </div>158          {d?.claim_id && (159            <p className="mono break-all text-[11px] text-ink-3">160              claim {d.claim_id}161              {d.snapshot_id && <> · snapshot {d.snapshot_id}</>}162            </p>163          )}164          {state.source && state.source !== 'api' && <p className="text-[11px] text-ink-3">{state.source === 'history' ? 'From the claim history endpoint (the field-level provenance endpoint is not live yet).' : 'From the inline provenance shipped with the page.'}</p>}165        </div>166      )}167    </Sheet>168  );169}170171function Row({ k, children }: { k: string; children: React.ReactNode }) {172  return (173    <div>174      <dt>{k}</dt>175      <dd className="text-ink">{children}</dd>176    </div>177  );178}179180function tierHint(t: number): string {181  const n = Math.min(4, Math.max(1, Math.round(t)));182  return TIER_LABELS[n] ? `— ${TIER_LABELS[n]?.toLowerCase()} source` : '';183}184185function fromFallback(t: EvidenceTarget): ProvenanceDetail | null {186  const f = t.fallback;187  if (!f) return null;188  return {189    slug: t.slug,190    property: t.property,191    value: t.value,192    unit: f.unit ?? t.unit ?? null,193    source_id: f.source_id,194    source_name: f.source_name ?? null,195    domain: hostOf(f.url),196    url: f.url,197    tier: f.tier,198    confidence: f.confidence,199    extractor: f.extractor,200    observed_at: f.observed_at,201    valid_from: null,202    conflicts: [],203  };204}205206function fromHistory(t: EvidenceTarget, items: Claim[]): ProvenanceDetail | null {207  const current = items.find((c) => c.status === 'current') ?? items[0];208  const base = fromFallback(t);209  if (!current) return base;210  return {211    ...(base ?? { slug: t.slug, property: t.property, value: t.value, tier: null }),212    value: current.value ?? t.value,213    unit: current.unit ?? base?.unit ?? null,214    claim_id: current.id,215    source_name: current.source_name ?? base?.source_name ?? null,216    domain: hostOf(current.source_url) ?? base?.domain ?? null,217    url: current.source_url ?? base?.url ?? null,218    tier: current.tier,219    confidence: current.confidence,220    extractor: current.extractor,221    observed_at: current.observed_at,222    valid_from: current.valid_from,223    valid_to: current.valid_to,224    status: current.status,225    conflicts: items.filter((c) => c.status === 'conflicting'),226    history_count: items.length,227  };228}229