HTML 77.2%
TypeScript 10.5%
Python 9.6%
JavaScript 2.5%
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