SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
34.7 KB · 598 lines tsx
Raw Blame History
1"use client";23import Link from "next/link";4import { useMemo, useState, type ReactNode } from "react";5import { Badge, Button, ScopeBadge, SemanticsLabel, SkeletonRows, Table, TBody, Td, Th, THead, Tr } from "@/components/ui";6import { cn } from "@/components/ui/cn";7import { DASH, fmtInt, fmtMw, fmtUsd, fmtValue } from "@/lib/format";8import { entityHref } from "@/lib/routes";9import type { TraceResult } from "@/lib/admin-api";10import { ErrorNote, ExtLink, fmtBytes, JsonView, KV, LevelBadge, Mono, RefreshButton, Stat } from "./primitives";11import { AdminPageHeader } from "./shell";12import { useAdminQuery } from "./use-admin-query";1314/* ------------------------------------------------------------------------------------------15   EXTRACTION DEBUGGER (§94–95): the pipeline for ONE document, stage by stage, without publishing.16   SOURCE DOCUMENT → RAW FETCH → PARSED TEXT → STRUCTURED DATA → EXTRACTED ENTITIES → EXTRACTED CLAIMS →17   NORMALIZED VALUES → MATCH CANDIDATES → RECONCILIATION → RESULTING DATABASE CHANGES.18   Data comes from GET /api/admin/documents/:id/trace (worker dry run); nothing is invented — when the19   worker is unreachable the ladder renders its error state.20   ------------------------------------------------------------------------------------------ */2122interface Highlight {23  start: number;24  end: number;25  kind: "mw" | "usd";26  raw: string;27}2829/** Same regexes as apps/worker/src/trace.ts highlightFigures — MW / GW and money figures. */30export function highlightFigures(text: string): Highlight[] {31  const out: Highlight[] = [];32  for (const m of text.matchAll(/(\d{1,3}(?:[.,]\d{3})+|\d+(?:[.,]\d+)?)\+?\s?(gigawatts?|gw|megawatts?|mw)\b/gi)) out.push({ start: m.index ?? 0, end: (m.index ?? 0) + m[0].length, kind: "mw", raw: m[0] });33  for (const m of text.matchAll(/(?:US\$|USD|\$|€|£|A\$|C\$|S\$)\s?\d[\d.,]*\s*(?:trillion|billion|million|bn|m\b|b\b|k\b)?/gi)) out.push({ start: m.index ?? 0, end: (m.index ?? 0) + m[0].length, kind: "usd", raw: m[0] });34  return out.sort((a, b) => a.start - b.start);35}3637/** announcement.figures (connector-defined) → highlights when the entries carry offsets. */38function figuresFromAnnouncement(a: Record<string, unknown> | null): Highlight[] {39  const figs = a?.figures;40  if (!Array.isArray(figs)) return [];41  const out: Highlight[] = [];42  for (const f of figs as Array<Record<string, unknown>>) {43    const start = typeof f.start === "number" ? f.start : typeof f.offset === "number" ? f.offset : null;44    const end = typeof f.end === "number" ? f.end : start !== null && typeof f.raw === "string" ? start + f.raw.length : null;45    if (start === null || end === null || end <= start) continue;46    const unit = String(f.unit ?? f.kind ?? "").toLowerCase();47    out.push({ start, end, kind: unit.includes("usd") || unit.includes("$") || unit === "money" ? "usd" : "mw", raw: typeof f.raw === "string" ? f.raw : String(f.value ?? "") });48  }49  return out.sort((a, b) => a.start - b.start);50}5152/** Text with figure spans highlighted (mw = accent, usd = permitting tone) and an optional evidence window emphasised. */53export function HighlightedText({ text, highlights, focus, className, maxHeight = 320 }: { text: string; highlights: Highlight[]; focus?: { start: number; end: number } | null; className?: string; maxHeight?: number | null }) {54  const parts = useMemo(() => {55    const nodes: ReactNode[] = [];56    let i = 0;57    const marks = [...highlights].filter((h) => h.start >= 0 && h.end <= text.length && h.end > h.start);58    // merge focus as a soft background59    for (const [k, h] of marks.entries()) {60      if (h.start < i) continue;61      if (h.start > i) nodes.push(<span key={`t${k}`}>{text.slice(i, h.start)}</span>);62      nodes.push(63        <mark key={`m${k}`} className={cn("rounded-[2px] px-[2px] font-medium", h.kind === "mw" ? "bg-accent/15 text-accent-ink" : "bg-st-permitting/15 text-st-permitting")} title={h.kind === "mw" ? "capacity figure" : "money figure"}>64          {text.slice(h.start, h.end)}65        </mark>,66      );67      i = h.end;68    }69    if (i < text.length) nodes.push(<span key="tail">{text.slice(i)}</span>);70    return nodes;71  }, [text, highlights]);72  if (!text) return <p className="text-[12px] text-ink-4">No parsed text.</p>;73  return (74    <div className={cn("overflow-auto whitespace-pre-wrap break-words rounded-xs border border-hair bg-paper px-3 py-2 text-[12.5px] leading-[1.6] text-ink-2", className)} style={{ maxHeight: maxHeight ?? undefined }}>75      {focus ? (76        <>77          <span className="opacity-60">{text.slice(0, focus.start)}</span>78          <span className="bg-surface-3 text-ink">{text.slice(focus.start, focus.end)}</span>79          <span className="opacity-60">{text.slice(focus.end)}</span>80        </>81      ) : (82        parts83      )}84    </div>85  );86}8788const STAGES = [89  { id: "document", n: 1, label: "Source document" },90  { id: "fetch", n: 2, label: "Raw fetch" },91  { id: "text", n: 3, label: "Parsed text" },92  { id: "records", n: 4, label: "Structured data" },93  { id: "entities", n: 5, label: "Extracted entities" },94  { id: "claims", n: 6, label: "Extracted claims" },95  { id: "normalized", n: 7, label: "Normalized values" },96  { id: "matches", n: 8, label: "Match candidates" },97  { id: "reconciliation", n: 9, label: "Reconciliation (dry run)" },98  { id: "changes", n: 10, label: "Resulting database changes" },99] as const;100101function Stage({ n, label, count, tone, children, defaultOpen = true, aside }: { n: number; label: string; count?: ReactNode; tone?: "ok" | "warn" | "danger" | "muted"; children: ReactNode; defaultOpen?: boolean; aside?: ReactNode }) {102  const [open, setOpen] = useState(defaultOpen);103  return (104    <li className="relative pl-9">105      <span aria-hidden className={cn("absolute left-0 top-1 flex size-6 items-center justify-center rounded-full border text-[11px] font-semibold", tone === "danger" ? "border-danger/40 bg-danger/10 text-danger" : tone === "warn" ? "border-warn/40 bg-warn/10 text-warn" : tone === "muted" ? "border-hair bg-surface text-ink-4" : "border-hair-2 bg-surface text-ink")}>106        {n}107      </span>108      <div className="rounded-sm border border-hair bg-surface">109        <button type="button" onClick={() => setOpen((o) => !o)} aria-expanded={open} className="flex min-h-10 w-full items-center gap-2 px-3 text-left">110          <span className="label-caps text-ink">{label}</span>111          {count !== undefined && <span className="figure text-[12px] text-ink-3">{count}</span>}112          <span className="ml-auto flex items-center gap-2">113            {aside}114            <span className="text-[11px] text-ink-4">{open ? "hide" : "show"}</span>115          </span>116        </button>117        {open && <div className="border-t border-hair px-3 py-2.5">{children}</div>}118      </div>119    </li>120  );121}122123function num(v: unknown): number | null {124  return typeof v === "number" && Number.isFinite(v) ? v : null;125}126function str(v: unknown): string | null {127  return typeof v === "string" && v ? v : null;128}129130const MW_FIELDS = ["itCapacityMw", "totalPowerMw", "plannedPowerMw", "utilityCapacityMw", "gridConnectionMw", "ultimateCampusMw", "plannedMw", "it_capacity_mw", "total_power_mw", "planned_power_mw", "planned_mw"];131const DATE_FIELDS = ["openedOn", "announcedOn", "constructionStartedOn", "expectedOpening", "approvedOn", "permitFiledOn", "opened_on", "announced_on", "expected_opening"];132133/** KV of the fields that matter for a normalized entity (MW columns, status, dates, geo, ids). */134function NormalizedEntity({ e }: { e: Record<string, unknown> }) {135  const geo = (e.geo as Record<string, unknown> | undefined) ?? null;136  const items: Array<{ k: ReactNode; v: ReactNode }> = [];137  for (const f of MW_FIELDS) if (num(e[f]) !== null) items.push({ k: <Mono>{f}</Mono>, v: <span className="figure">{fmtMw(num(e[f]))}</span> });138  if (num(e.investmentUsd ?? e.investment_usd) !== null) items.push({ k: <Mono>investmentUsd</Mono>, v: <span className="figure">{fmtUsd(num(e.investmentUsd ?? e.investment_usd))}</span> });139  if (str(e.status)) items.push({ k: "status", v: <Badge size="xs" tone="outline">{String(e.status)}</Badge> });140  if (str(e.facilityType ?? e.facility_type)) items.push({ k: "type", v: String(e.facilityType ?? e.facility_type) });141  for (const f of DATE_FIELDS) if (str(e[f])) items.push({ k: <Mono>{f}</Mono>, v: <span className="figure">{String(e[f])}</span> });142  if (geo && num(geo.lat) !== null && num(geo.lng) !== null) items.push({ k: "geo", v: <span className="figure">{num(geo.lat)!.toFixed(5)}, {num(geo.lng)!.toFixed(5)} {geo.precision ? `(${String(geo.precision)})` : ""}</span> });143  else if (num(e.lat) !== null && num(e.lng) !== null) items.push({ k: "geo", v: <span className="figure">{num(e.lat)!.toFixed(5)}, {num(e.lng)!.toFixed(5)}</span> });144  for (const f of ["city", "regionName", "countryIso2", "country_iso2", "address", "operatorName", "operator", "metro"]) if (str(e[f])) items.push({ k: f, v: String(e[f]) });145  const aiEv = str(e.aiEvidence ?? e.ai_evidence);146  if (aiEv) items.push({ k: "aiEvidence", v: <Badge size="xs" tone={aiEv === "confirmed" || aiEv === "likely" ? "accent" : "outline"}>{aiEv}</Badge> });147  if (str(e.projectClass ?? e.project_class)) items.push({ k: "projectClass", v: <Mono>{String(e.projectClass ?? e.project_class)}</Mono> });148  const ids = (e.externalIds as Record<string, unknown> | undefined) ?? null;149  if (ids && Object.keys(ids).length) items.push({ k: "externalIds", v: <Mono wrap>{Object.entries(ids).map(([k, v]) => `${k}=${String(v)}`).join(" · ")}</Mono> });150  return (151    <div className="rounded-xs border border-hair px-2.5 py-2">152      <div className="mb-1 flex flex-wrap items-center gap-2 text-[12.5px]">153        <Badge size="xs" tone="outline" mono>154          {String(e.type ?? e.kind ?? "entity")}155        </Badge>156        <span className="font-medium text-ink">{str(e.name) ?? DASH}</span>157        {str(e.key) && <Mono className="text-ink-4">{String(e.key)}</Mono>}158      </div>159      {items.length ? <KV items={items} cols={2} /> : <p className="text-[12px] text-ink-4">No capacity, status, date or geo field on this entity.</p>}160    </div>161  );162}163164function DebuggerHeader({ id, doc, fixture }: { id: string; doc: Record<string, unknown> | null; fixture: boolean }) {165  const url = doc && typeof doc.url === "string" ? doc.url : null;166  const connectorId = doc && typeof doc.connectorId === "string" ? doc.connectorId : null;167  return (168    <AdminPageHeader169      eyebrow={170        <span className="inline-flex flex-wrap items-center gap-1.5">171          <Link href="/admin/documents" className="hover:text-ink">172            Documents173          </Link>174          {connectorId && (175            <>176              <span>/</span>177              <Link href={`/admin/connectors/${encodeURIComponent(connectorId)}`} className="font-mono hover:text-ink">178                {connectorId}179              </Link>180            </>181          )}182          <span>/ extraction debugger</span>183        </span>184      }185      title={url ? <span className="break-all font-mono text-[15px] sm:text-[17px]">{url.replace(/^https?:\/\//, "")}</span> : <span className="font-mono">{id}</span>}186      description={fixture ? "Sample trace for layout only — no worker call was made." : "Dry run of the full pipeline for this document: nothing below is written to the database."}187    />188  );189}190191export function ExtractionDebugger({ id, fixture }: { id: string; fixture?: TraceResult | null }) {192  const [live, setLive] = useState(false);193  const q = useAdminQuery<TraceResult>(fixture ? null : `documents/${encodeURIComponent(id)}/trace`, { query: live ? { live: 1 } : undefined, timeoutMs: 120_000 });194  const t = fixture ?? q.data;195  const excerpt = t?.text.excerpt ?? "";196  const highlights = useMemo(() => {197    const fromAnn = figuresFromAnnouncement(t?.announcement ?? null);198    return fromAnn.length ? fromAnn : highlightFigures(excerpt);199  }, [t, excerpt]);200  const [focusClaim, setFocusClaim] = useState<number | null>(null);201  const unreachable = q.error && /worker unreachable|502|fetch failed/i.test(q.error);202203  if (!fixture && q.error && !q.data) {204    return (205      <div className="space-y-2">206        <DebuggerHeader id={id} doc={null} fixture={false} />207        <ErrorNote error={unreachable ? `The maintenance worker did not answer (${q.error}). The trace is a dry run executed by the worker (GET /trace/<id>); start it or check DCI_WORKER_URL on the API.` : q.error} onRetry={() => void q.refresh()} />208        <p className="text-[11.5px] text-ink-4">Nothing is shown from cache: a trace is recomputed on demand and never stored.</p>209      </div>210    );211  }212  if (!t) {213    return (214      <div>215        <DebuggerHeader id={id} doc={null} fixture={false} />216        <SkeletonRows rows={8} />217      </div>218    );219  }220221  const doc = t.document ?? {};222  const rec = t.reconciliation;223  const entityByKey = new Map<string, Record<string, unknown>>();224  for (const e of t.entities) if (str(e.key)) entityByKey.set(String(e.key), e);225226  return (227    <div>228      <DebuggerHeader id={id} doc={t.document} fixture={Boolean(fixture)} />229      <div className="mb-3 flex flex-wrap items-center gap-2">230        {fixture && (231          <Badge tone="danger" size="sm" className="uppercase tracking-[0.06em]">232            Fixture — not real data233          </Badge>234        )}235        {!fixture && <Badge size="xs" tone="outline">{t.fetch?.source === "live" ? "live re-fetch" : "archived body"}</Badge>}236        {t.error && <Badge size="xs" tone="danger">{t.error}</Badge>}237        <span className="ml-auto flex items-center gap-2">238          {!fixture && (239            <Button240              size="xs"241              variant="secondary"242              disabled={q.loading || q.refreshing}243              onClick={() => {244                setLive(true);245                if (live) void q.refresh();246              }}247              title="Re-fetch the page from the source and run the trace on the fresh body"248            >249              Re-trace live250            </Button>251          )}252          {!fixture && <RefreshButton onClick={() => void q.refresh()} busy={q.refreshing} updatedAt={q.updatedAt} label="Re-run" />}253        </span>254      </div>255256      <div className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">257        <Stat label="Records" value={fmtInt(t.records.length)} />258        <Stat label="Entities" value={fmtInt(t.entities.length)} note={`${fmtInt(t.validation.valid)} valid · ${fmtInt(t.validation.rejected)} rejected`} tone={t.validation.rejected > 0 ? "warn" : undefined} />259        <Stat label="Claims" value={fmtInt(t.claims.length)} note={`${fmtInt(t.claims.filter((c) => c.evidence).length)} with evidence`} />260        <Stat label="Non-site scope" value={fmtInt(t.claims.filter((c) => !["building", "facility", "campus"].includes(c.scope)).length)} note="stored, never summed" />261        <Stat label="Would create" value={rec ? fmtInt(rec.created) : DASH} note={rec ? `${fmtInt(rec.updated)} updated · ${fmtInt(rec.merged)} merged` : "no dry run"} />262        <Stat label="Would reject" value={rec ? fmtInt(rec.rejected + rec.projectsVetoed) : DASH} tone={rec && rec.rejected + rec.projectsVetoed > 0 ? "warn" : undefined} note={rec ? `${fmtInt(rec.projectsVetoed)} projects vetoed` : undefined} />263      </div>264265      <ol className="relative space-y-2 border-l border-hair pl-0 [&>li]:before:absolute [&>li]:before:-left-px [&>li]:before:top-4 [&>li]:before:h-px [&>li]:before:w-3 [&>li]:before:bg-hair">266        <Stage n={1} label={STAGES[0].label} aside={str(doc.url) ? <ExtLink href={String(doc.url)}>open</ExtLink> : undefined}>267          <KV268            cols={2}269            items={[270              { k: "Id", v: <Mono>{str(doc.id) ?? id}</Mono> },271              { k: "Connector", v: str(doc.connectorId) ? <Link href={`/admin/connectors/${encodeURIComponent(String(doc.connectorId))}`} className="font-mono hover:text-accent">{String(doc.connectorId)}</Link> : DASH },272              { k: "URL", v: str(doc.url) ? <ExtLink href={String(doc.url)} /> : DASH },273              { k: "Title", v: str(doc.title) ?? DASH },274              { k: "Page type", v: str(doc.pageType) ? <Badge size="xs" mono tone="outline">{String(doc.pageType)}</Badge> : DASH },275              { k: "Classifier", v: str(doc.classifier) ? <Mono wrap>{String(doc.classifier)}</Mono> : DASH },276              { k: "Content hash", v: str(doc.contentHash) ? <Mono>{String(doc.contentHash).slice(0, 16)}…</Mono> : DASH },277              { k: "Extractor", v: str(doc.extractorVersion) ? <Mono>{String(doc.extractorVersion)}</Mono> : DASH },278              { k: "Last extraction", v: doc.extractOk === false ? <span className="text-danger">failed{str(doc.error) ? ` — ${String(doc.error)}` : ""}</span> : doc.extractOk === true ? `${fmtInt(num(doc.extractCount) ?? 0)} entities` : DASH },279              { k: "Quarantined", v: doc.quarantined ? <Badge size="xs" tone="warn">yes</Badge> : "no" },280            ]}281          />282        </Stage>283284        <Stage n={2} label={STAGES[1].label} tone={t.fetch ? (t.fetch.status >= 400 ? "danger" : undefined) : "muted"}>285          {t.fetch ? (286            <KV287              cols={3}288              items={[289                { k: "Source", v: <Badge size="xs" tone={t.fetch.source === "live" ? "accent" : "outline"}>{t.fetch.source}</Badge> },290                { k: "HTTP", v: <Mono className={t.fetch.status >= 400 ? "text-danger" : undefined}>{t.fetch.status}</Mono> },291                { k: "Content type", v: t.fetch.contentType ?? DASH },292                { k: "Bytes", v: fmtBytes(t.fetch.bytes) },293                { k: "Fetcher", v: <span className="inline-flex items-center gap-1.5"><LevelBadge level={t.fetch.level} /> <Mono>{t.fetch.fetcher}</Mono></span> },294                { k: "Storage key", v: t.fetch.storageKey ? <Mono wrap>{t.fetch.storageKey}</Mono> : DASH },295              ]}296            />297          ) : (298            <p className="text-[12px] text-ink-4">No body available — the document was never fetched or its archive is missing.</p>299          )}300        </Stage>301302        <Stage n={3} label={STAGES[2].label} count={`${fmtInt(t.text.length)} chars`} aside={<span className="text-[11px] text-ink-4">{fmtInt(highlights.length)} figures highlighted</span>}>303          <div className="mb-2 flex flex-wrap items-center gap-2 text-[12.5px]">304            {t.text.title && <span className="font-medium text-ink">{t.text.title}</span>}305            {t.text.classification && (306              <>307                <Badge size="xs" mono tone="outline">308                  {t.text.classification.pageType}309                </Badge>310                <span className="text-ink-3">rule <Mono>{t.text.classification.rule}</Mono></span>311                {t.text.classification.eventType && <Badge size="xs" tone="accent">{t.text.classification.eventType}</Badge>}312                {t.text.classification.mw.length > 0 && <span className="figure text-ink-3">MW seen: {t.text.classification.mw.join(", ")}</span>}313              </>314            )}315          </div>316          <HighlightedText text={excerpt} highlights={highlights} focus={focusClaim !== null ? t.claims[focusClaim]?.evidence ?? null : null} />317          {t.text.length > excerpt.length && <p className="mt-1 text-[11px] text-ink-4">Excerpt: first {fmtInt(excerpt.length)} of {fmtInt(t.text.length)} characters.</p>}318          {t.announcement && (319            <div className="mt-2">320              <div className="label-caps mb-1">Announcement classification</div>321              <JsonView value={t.announcement} maxHeight={200} collapsible />322            </div>323          )}324        </Stage>325326        <Stage n={4} label={STAGES[3].label} count={fmtInt(t.records.length)} tone={t.records.length ? undefined : "muted"} defaultOpen={t.records.length > 0}>327          {t.records.length === 0 ? (328            <p className="text-[12px] text-ink-4">The parser produced no structured record from this body.</p>329          ) : (330            <div className="space-y-2">331              {t.records.map((r, i) => (332                <div key={`${r.key}-${i}`} className="rounded-xs border border-hair px-2.5 py-2">333                  <div className="mb-1 flex flex-wrap items-center gap-2 text-[12.5px]">334                    <Badge size="xs" tone="outline" mono>335                      {r.kind}336                    </Badge>337                    <Mono>{r.key}</Mono>338                    {r.pageType && <span className="text-ink-3">{r.pageType}</span>}339                    {r.certainty !== null && <span className="figure text-ink-3">certainty {(r.certainty * 100).toFixed(0)}%</span>}340                  </div>341                  <JsonView value={r.data} maxHeight={220} collapsible />342                  {Object.keys(r.methods).length > 0 && (343                    <p className="mt-1 text-[11px] text-ink-4">344                      methods: {Object.entries(r.methods).map(([k, v]) => `${k}=${v}`).join(" · ")}345                    </p>346                  )}347                </div>348              ))}349            </div>350          )}351        </Stage>352353        <Stage n={5} label={STAGES[4].label} count={fmtInt(t.entities.length)} tone={t.validation.rejected > 0 ? "warn" : t.entities.length ? undefined : "muted"} defaultOpen={t.entities.length > 0}>354          <p className="mb-2 text-[12px] text-ink-3">355            Validation: <span className="figure text-ink">{fmtInt(t.validation.total)}</span> total · <span className="figure text-ok">{fmtInt(t.validation.valid)}</span> valid · <span className={cn("figure", t.validation.rejected ? "text-danger" : "text-ink")}>{fmtInt(t.validation.rejected)}</span> rejected356          </p>357          {t.entities.length === 0 ? (358            <p className="text-[12px] text-ink-4">No entity — a record needs an identifying name and enough fields to become one.</p>359          ) : (360            <ul className="divide-y divide-hair">361              {t.entities.map((e, i) => (362                <li key={i} className="flex flex-wrap items-center gap-2 py-1.5 text-[12.5px]">363                  <Badge size="xs" tone="outline" mono>364                    {String(e.type ?? e.kind ?? "entity")}365                  </Badge>366                  <span className="font-medium text-ink">{str(e.name) ?? DASH}</span>367                  {str(e.key) && <Mono className="text-ink-4">{String(e.key)}</Mono>}368                  {str(e.status) && <span className="text-ink-3">{String(e.status)}</span>}369                </li>370              ))}371            </ul>372          )}373          {t.validation.issues.length > 0 && (374            <div className="mt-2">375              <div className="label-caps mb-1">Issues</div>376              <ul className="divide-y divide-hair text-[12px]">377                {t.validation.issues.map((is, i) => (378                  <li key={i} className="flex flex-wrap gap-2 py-1">379                    <Badge size="xs" tone={/error|reject/i.test(String(is.level ?? is.severity ?? "")) ? "danger" : "warn"}>380                      {String(is.level ?? is.severity ?? "issue")}381                    </Badge>382                    {(is.field ?? is.path) && <Mono>{String(is.field ?? is.path)}</Mono>}383                    <span className="text-ink-2">{String(is.message ?? fmtValue(is))}</span>384                  </li>385                ))}386              </ul>387            </div>388          )}389        </Stage>390391        <Stage n={6} label={STAGES[5].label} count={fmtInt(t.claims.length)} tone={t.claims.length ? undefined : "muted"} defaultOpen={t.claims.length > 0}>392          {t.claims.length === 0 ? (393            <p className="text-[12px] text-ink-4">No numerical claim — either the text carries no MW / money figure or no supporting sentence was found (no sentence, no claim).</p>394          ) : (395            <Table className="text-[12.5px]" dense>396              <THead>397                <Tr>398                  <Th>Entity</Th>399                  <Th>Field</Th>400                  <Th numeric>Value</Th>401                  <Th>Scope</Th>402                  <Th className="hidden md:table-cell">Semantics</Th>403                  <Th>Evidence sentence</Th>404                </Tr>405              </THead>406              <TBody>407                {t.claims.map((c, i) => {408                  const ev = c.evidence;409                  const evHi = ev ? highlightFigures(ev.text) : [];410                  return (411                    <Tr key={i} className={cn(focusClaim === i && "bg-surface-2/60")} onMouseEnter={() => setFocusClaim(i)} onMouseLeave={() => setFocusClaim(null)}>412                      <Td>413                        <Mono wrap>{c.entityKey}</Mono>414                      </Td>415                      <Td>416                        <Mono>{c.field}</Mono>417                      </Td>418                      <Td numeric className="figure">419                        {c.unit === "MW" ? fmtMw(c.value) : fmtUsd(c.value)}420                      </Td>421                      <Td>422                        <div className="flex flex-col items-start gap-0.5">423                          <ScopeBadge scope={c.scope} />424                          <Mono className="text-[10.5px] text-ink-4">{c.scopeReason}</Mono>425                        </div>426                      </Td>427                      <Td className="hidden md:table-cell">{c.semantics ? <SemanticsLabel semantics={c.semantics} className="text-[12px] text-ink-2" /> : <span className="text-ink-4">{DASH}</span>}</Td>428                      <Td className="max-w-[420px]">429                        {ev ? (430                          <span className="text-[12px] leading-snug text-ink-2">431                            <HighlightedText text={ev.text} highlights={evHi} className="border-0 bg-transparent px-0 py-0" maxHeight={null} />432                            <span className="figure text-[10.5px] text-ink-4">433                              @{ev.start}–{ev.end}434                            </span>435                          </span>436                        ) : (437                          <span className="text-[12px] text-ink-4">no sentence (structured field)</span>438                        )}439                      </Td>440                    </Tr>441                  );442                })}443              </TBody>444            </Table>445          )}446          <p className="mt-2 text-[11px] text-ink-4">Only building / facility / campus scopes may populate a record&rsquo;s capacity or investment column; portfolio, company, country, metro and unknown scopes are stored as claims and shown as evidence.</p>447        </Stage>448449        <Stage n={7} label={STAGES[6].label} count={fmtInt(t.entities.length)} tone={t.entities.length ? undefined : "muted"} defaultOpen={t.entities.length > 0}>450          {t.entities.length === 0 ? <p className="text-[12px] text-ink-4">Nothing to normalize.</p> : <div className="grid gap-2 lg:grid-cols-2">{t.entities.map((e, i) => <NormalizedEntity key={i} e={e} />)}</div>}451        </Stage>452453        <Stage n={8} label={STAGES[7].label} count={fmtInt(t.matches.length)} tone={t.matches.length ? undefined : "muted"} defaultOpen={t.matches.length > 0}>454          {t.matches.length === 0 ? (455            <p className="text-[12px] text-ink-4">No facility resolution attempted (no facility entity, or the entity kind is not matched).</p>456          ) : (457            <div className="space-y-2">458              {t.matches.map((m, i) => {459                const ent = entityByKey.get(m.entityKey);460                return (461                  <div key={i} className="rounded-xs border border-hair px-2.5 py-2">462                    <div className="mb-1 flex flex-wrap items-center gap-2 text-[12.5px]">463                      <span className="font-medium text-ink">{str(ent?.name) ?? m.entityKey}</span>464                      <Mono className="text-ink-4">{m.entityKey}</Mono>465                      <Badge size="xs" tone={m.matchedId ? "ok" : "outline"}>{m.how}</Badge>466                      {m.matchedId && (467                        <Link href={entityHref("facility", m.matchedId)} target="_blank" className="font-mono text-[12px] text-ink-2 hover:text-accent">468                          → {m.matchedId}469                        </Link>470                      )}471                    </div>472                    {m.candidates.length === 0 ? (473                      <p className="text-[12px] text-ink-4">No candidate within the matching window — a real run would create a new facility.</p>474                    ) : (475                      <Table className="text-[12px]" dense>476                        <THead>477                          <Tr>478                            <Th>Candidate</Th>479                            <Th>Operator</Th>480                            <Th>City</Th>481                            <Th numeric>Distance</Th>482                            <Th>Score</Th>483                            <Th>Reasons</Th>484                          </Tr>485                        </THead>486                        <TBody>487                          {m.candidates.map((c) => (488                            <Tr key={c.id} className={cn(c.id === m.matchedId && "bg-ok/5")}>489                              <Td>490                                <Link href={entityHref("facility", c.id)} target="_blank" className="font-medium text-ink hover:text-accent hover:underline">491                                  {c.name}492                                </Link>493                                <div className="text-[10.5px] text-ink-4">494                                  <Mono>{c.id}</Mono>495                                </div>496                              </Td>497                              <Td muted>{c.operatorName ?? DASH}</Td>498                              <Td muted>{c.city ?? DASH}</Td>499                              <Td numeric>{c.distanceKm == null ? DASH : c.distanceKm < 1 ? `${Math.round(c.distanceKm * 1000)} m` : `${c.distanceKm.toFixed(1)} km`}</Td>500                              <Td>501                                <div className="flex items-center gap-1.5">502                                  <div className="h-1.5 w-16 rounded-full bg-surface-3">503                                    <div className={cn("h-full rounded-full", c.score >= 0.92 ? "bg-ok" : c.score >= 0.6 ? "bg-warn" : "bg-ink-4")} style={{ width: `${Math.min(100, c.score * 100)}%` }} />504                                  </div>505                                  <span className="figure">{(c.score * 100).toFixed(0)}%</span>506                                </div>507                              </Td>508                              <Td>509                                <div className="flex flex-wrap gap-1">510                                  {c.reasons.map((r) => (511                                    <Badge key={r} size="xs" mono tone="outline">512                                      {r}513                                    </Badge>514                                  ))}515                                </div>516                              </Td>517                            </Tr>518                          ))}519                        </TBody>520                      </Table>521                    )}522                  </div>523                );524              })}525              <p className="text-[11px] text-ink-4">≥ 0.92 merges automatically, 0.60–0.92 goes to the duplicate workbench, below 0.60 creates a new record.</p>526            </div>527          )}528        </Stage>529530        <Stage n={9} label={STAGES[8].label} tone={rec ? (rec.rejected + rec.projectsVetoed > 0 ? "warn" : "ok") : "muted"}>531          {!rec ? (532            <p className="text-[12px] text-ink-4">The dry-run ingest did not run (no valid entity, or the trace stopped earlier{t.error ? `: ${t.error}` : ""}).</p>533          ) : (534            <div className="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-6">535              <Stat label="Created" value={fmtInt(rec.created)} tone={rec.created ? "ok" : undefined} />536              <Stat label="Updated" value={fmtInt(rec.updated)} />537              <Stat label="Unchanged" value={fmtInt(rec.unchanged)} />538              <Stat label="Merged" value={fmtInt(rec.merged)} />539              <Stat label="Pending matches" value={fmtInt(rec.pendingMatches)} tone={rec.pendingMatches ? "warn" : undefined} />540              <Stat label="Rejected" value={fmtInt(rec.rejected)} tone={rec.rejected ? "danger" : undefined} />541              <Stat label="Events" value={fmtInt(rec.events)} />542              <Stat label="Provenance rows" value={fmtInt(rec.provenanceRows)} />543              <Stat label="Claims" value={fmtInt(rec.claims)} note={`${fmtInt(rec.unscopedClaims)} unscoped`} />544              <Stat label="Quality flags" value={fmtInt(rec.qualityFlags)} tone={rec.qualityFlags ? "warn" : undefined} />545              <Stat label="Projects vetoed" value={fmtInt(rec.projectsVetoed)} tone={rec.projectsVetoed ? "warn" : undefined} note="evidence threshold / class" />546              <Stat label="Refs" value={fmtInt(rec.refs.length)} />547            </div>548          )}549        </Stage>550551        <Stage n={10} label={STAGES[9].label} count={rec ? fmtInt(rec.changes.length) : undefined} tone={rec ? undefined : "muted"} defaultOpen={Boolean(rec && rec.changes.length)}>552          {!rec ? (553            <p className="text-[12px] text-ink-4">No changes computed.</p>554          ) : (555            <div className="space-y-2">556              {rec.refs.length > 0 && (557                <div>558                  <div className="label-caps mb-1">Entities a real run would touch</div>559                  <ul className="flex flex-wrap gap-1.5">560                    {rec.refs.map((r, i) => (561                      <li key={`${r.type}-${r.id}-${i}`}>562                        <Link href={entityHref(r.type, r.id)} target="_blank" className="inline-flex h-7 items-center gap-1.5 rounded-xs border border-hair px-2 text-[12px] text-ink hover:text-accent">563                          <span className="text-ink-3">{r.type}</span>564                          <Mono>{r.id}</Mono>565                        </Link>566                      </li>567                    ))}568                  </ul>569                </div>570              )}571              {rec.changes.length === 0 ? <p className="text-[12px] text-ink-4">A real run would write nothing new for this document.</p> : <JsonView value={rec.changes} maxHeight={420} />}572              <p className="text-[11px] text-ink-4">Dry run: nothing above was written. Use “Reprocess” on the document to apply with the current parser.</p>573            </div>574          )}575        </Stage>576      </ol>577578      <details className="mt-3 rounded-sm border border-hair bg-surface">579        <summary className="flex min-h-9 cursor-pointer list-none items-center gap-2 px-3 text-[12.5px] text-ink-2 [&::-webkit-details-marker]:hidden">580          <span className="label-caps">Worker log</span>581          <span className="figure text-ink-3">{fmtInt(t.logs.length)} lines</span>582        </summary>583        <div className="max-h-[320px] overflow-auto border-t border-hair bg-paper px-2 py-1 font-mono text-[11.5px] leading-[1.5]">584          {t.logs.length === 0 ? (585            <span className="text-ink-4">empty</span>586          ) : (587            t.logs.map((l, i) => (588              <div key={i} className={cn("border-b border-hair/60 py-0.5 last:border-0", /^error/.test(l) && "text-danger", /^warn/.test(l) && "text-warn", !/^(error|warn)/.test(l) && "text-ink-2")}>589                {l}590              </div>591            ))592          )}593        </div>594      </details>595    </div>596  );597}598