spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
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’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