disclosure-bureau/web/components/frontmatter-panel.tsx

437 lines
23 KiB
TypeScript
Raw Normal View History

/**
* Universal frontmatter renderer.
*
* Introspects ANY entity/doc/page/etc. frontmatter and renders each field with
* its semantic UI primitive. The schema (CLAUDE-schema-full.md) IS the contract.
*
* Grouping strategy (in order):
* IDENTITY canonical_name, *_id, aliases, disambiguation_note
* CLASSIFICATION highest_classification, classification_markings, language_detected, content_classification
* METRICS total_mentions, documents_count, page_count, totals, qualities
* RELATIONS primary_*, key_entities, observed_in_event, observers, related, ...
* EVIDENCE redactions, signatures_observed, tables_detected, images_detected
* TEMPORAL date_start/end, dates, last_*
* SPATIAL coordinates, primary_location
* ENRICHMENT enrichment_status, external_sources, verified_facts
* FLAGS flags
*/
import {
Users, Building2, MapPin, CalendarRange, Disc3, Plane, Crosshair, BookOpen,
Files, Image as ImageIcon, Table2, Eye, AlertTriangle, FileSearch,
FileWarning, Stamp, PenSquare, Bookmark, Hash, Layers, Zap,
} from "lucide-react";
import { FmWikiLink, FmWikiLinkList } from "./fm/wikilink";
import {
FmConfidence, FmClassification, FmEnrichmentBadge, FmContentChip,
FmPageTypeChip, FmLanguageChip, FmChip, FmStat, FmCoordinates,
FmQualityDot, FmFlag, FmTimestamp,
} from "./fm/badges";
import { FmBboxThumb } from "./fm/bbox-thumb";
import { FmExternalSources } from "./fm/external-sources";
import type { AnyFrontmatter, EntityRef, EntitiesExtracted } from "@/lib/fm-types";
interface Props {
fm: AnyFrontmatter;
/** Page-derived context — needed for bbox thumbnails to know which PNG to slice. */
pageCtx?: { docId: string; pageNum: number };
}
function isNonEmptyArray(x: unknown): x is unknown[] {
return Array.isArray(x) && x.length > 0;
}
/** Maps a class key under entities_extracted to its proper /e/<folder>/<id> namespace. */
const ENTITY_NS: Record<keyof EntitiesExtracted, { label: string; ns: string; icon: React.ReactNode }> = {
people: { label: "People", ns: "people", icon: <Users size={11} /> },
organizations: { label: "Organizations", ns: "org", icon: <Building2 size={11} /> },
locations: { label: "Locations", ns: "loc", icon: <MapPin size={11} /> },
events: { label: "Events", ns: "event", icon: <CalendarRange size={11} /> },
uap_objects: { label: "UAP Objects", ns: "uap", icon: <Disc3 size={11} /> },
vehicles: { label: "Vehicles", ns: "vehicle", icon: <Plane size={11} /> },
operations: { label: "Operations", ns: "op", icon: <Crosshair size={11} /> },
concepts: { label: "Concepts", ns: "concept", icon: <BookOpen size={11} /> },
};
/** Render an entity ref (extracted from a page) as a wiki-link.
* Uses the entity's `name` slugified best-effort. */
function entityRefToLink(ref: EntityRef, ns: string): string {
const name = ref.name ?? "";
if (!name) return "";
const id = name
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
return `${ns}/${id}`;
}
function Section({
title, icon, count, children,
}: { title: string; icon?: React.ReactNode; count?: number; children: React.ReactNode }) {
return (
<section>
<h3 className="font-mono text-[10px] text-[#8896aa] uppercase tracking-widest mb-2 flex items-center gap-1.5">
{icon}
<span>{title}</span>
{count !== undefined && <span className="text-[#5a6678]">({count})</span>}
</h3>
{children}
</section>
);
}
export function FrontmatterPanel({ fm, pageCtx }: Props) {
const sections: React.ReactNode[] = [];
/* ── CLASSIFICATION BANNER (top, prominent) ───────────────── */
const markings = fm.classification_markings ?? [];
const hasMarkings = markings.length > 0 || fm.highest_classification;
if (hasMarkings) {
sections.push(
<Section key="cls" title="Classification" icon={<Bookmark size={12} className="text-[#ff3344]" />}>
<div className="flex flex-wrap gap-1.5">
{fm.highest_classification && (
<FmClassification level={fm.highest_classification} caveats={undefined} />
)}
{markings.map((m, i) => (
<FmClassification key={i} level={m.level} caveats={m.caveats} />
))}
</div>
</Section>
);
}
/* ── ALIASES ──────────────────────────────────────────────── */
if (isNonEmptyArray(fm.aliases)) {
sections.push(
<Section key="aliases" title="Aliases" icon={<Hash size={12} />}>
<div className="flex flex-wrap gap-1">
{(fm.aliases as string[]).slice(0, 16).map((a, i) => (
<span key={i} className="font-mono text-[10px] text-[#7fdbff] bg-[rgba(127,219,255,0.06)] border border-[rgba(127,219,255,0.32)] rounded px-1.5 py-0.5">
{a}
</span>
))}
</div>
</Section>
);
}
/* ── DISAMBIGUATION ───────────────────────────────────────── */
if (fm.disambiguation_note) {
sections.push(
<Section key="disamb" title="Disambiguation" icon={<AlertTriangle size={12} className="text-[#f5c542]" />}>
<p className="text-sm text-[#f5c542] border-l-2 border-[#f5c542] pl-3">{fm.disambiguation_note}</p>
</Section>
);
}
/* ── CONTENT & PAGE TYPE ──────────────────────────────────── */
const cc = (fm.content_classification ?? []) as string[];
const wantsContent = cc.length > 0 || fm.page_type || fm.language_detected || isNonEmptyArray(fm.languages_detected);
if (wantsContent) {
sections.push(
<Section key="content" title="Content" icon={<FileSearch size={12} />}>
<div className="flex flex-wrap gap-1.5 items-center">
{fm.page_type && <FmPageTypeChip type={fm.page_type} />}
{cc.map((k, i) => <FmContentChip key={i} kind={k as Parameters<typeof FmContentChip>[0]["kind"]} />)}
{fm.language_detected && <FmLanguageChip code={fm.language_detected} />}
{(fm.languages_detected ?? []).map((l, i) => <FmLanguageChip key={i} code={l} />)}
</div>
</Section>
);
}
/* ── METRICS ──────────────────────────────────────────────── */
const metrics: React.ReactNode[] = [];
if (typeof fm.total_mentions === "number") metrics.push(<FmStat key="m1" icon={<Hash size={9} />} label="mentions" value={fm.total_mentions} color="cyan" />);
if (typeof fm.documents_count === "number") metrics.push(<FmStat key="m2" icon={<Files size={9} />} label="documents" value={fm.documents_count} color="cyan" />);
if (typeof fm.page_count === "number") metrics.push(<FmStat key="m3" icon={<Files size={9} />} label="pages" value={fm.page_count} color="amber" />);
if (typeof fm.total_redactions === "number" && fm.total_redactions > 0) metrics.push(<FmStat key="m4" icon={<FileWarning size={9} />} label="redactions" value={fm.total_redactions} color="red" />);
if (typeof fm.total_signatures === "number" && fm.total_signatures > 0) metrics.push(<FmStat key="m5" icon={<PenSquare size={9} />} label="signatures" value={fm.total_signatures} color="violet" />);
if (typeof fm.total_tables === "number" && fm.total_tables > 0) metrics.push(<FmStat key="m6" icon={<Table2 size={9} />} label="tables" value={fm.total_tables} color="cyan" />);
if (typeof fm.total_images === "number" && fm.total_images > 0) metrics.push(<FmStat key="m7" icon={<ImageIcon size={9} />} label="images" value={fm.total_images} color="cyan" />);
if (typeof fm.ocr_quality_score === "number") metrics.push(<FmStat key="m8" icon={<Zap size={9} />} label="ocr" value={`${Math.round(fm.ocr_quality_score * 100)}%`} color="soft" />);
if (typeof fm.vision_quality_score === "number") metrics.push(<FmStat key="m9" icon={<Eye size={9} />} label="vision" value={`${Math.round(fm.vision_quality_score * 100)}%`} color="soft" />);
if (metrics.length > 0) {
sections.push(
<Section key="metrics" title="Stats" icon={<Layers size={12} />}>
<div className="flex flex-wrap gap-2">{metrics}</div>
</Section>
);
}
/* ── KEY ENTITIES (document-level rollup) ─────────────────── */
if (fm.key_entities) {
const ke = fm.key_entities as EntitiesExtracted;
for (const k of Object.keys(ENTITY_NS) as Array<keyof EntitiesExtracted>) {
const refs = ke[k] ?? [];
if (!isNonEmptyArray(refs)) continue;
const meta = ENTITY_NS[k];
const links = (refs as EntityRef[]).map((r) => entityRefToLink(r, meta.ns)).filter(Boolean);
sections.push(
<Section key={`ke-${k}`} title={`Key ${meta.label}`} icon={meta.icon} count={refs.length}>
<FmWikiLinkList items={links} size="sm" />
</Section>
);
}
}
/* ── ENTITIES EXTRACTED (page-level) ──────────────────────── */
if (fm.entities_extracted) {
const ee = fm.entities_extracted as EntitiesExtracted;
for (const k of Object.keys(ENTITY_NS) as Array<keyof EntitiesExtracted>) {
const refs = ee[k] ?? [];
if (!isNonEmptyArray(refs)) continue;
const meta = ENTITY_NS[k];
const links = (refs as EntityRef[]).map((r) => entityRefToLink(r, meta.ns)).filter(Boolean);
sections.push(
<Section key={`ee-${k}`} title={meta.label} icon={meta.icon} count={refs.length}>
<FmWikiLinkList items={links} size="xs" />
</Section>
);
}
}
/* ── EVIDENCE: redactions ─────────────────────────────────── */
if (isNonEmptyArray(fm.redactions)) {
sections.push(
<Section key="redactions" title="Redactions" icon={<FileWarning size={12} className="text-[#ff3344]" />} count={fm.redactions!.length}>
<div className="flex flex-wrap gap-2">
{fm.redactions!.map((r, i) => (
<div key={i} className="flex items-center gap-2 border border-[rgba(255,51,68,0.32)] bg-[rgba(255,51,68,0.04)] rounded p-1.5">
<FmBboxThumb bbox={r.bbox} docId={pageCtx?.docId} pageNum={pageCtx?.pageNum} width={64} height={48} label={r.description} />
<div className="text-[10px] font-mono">
<div className="text-[#ff3344] font-semibold">{r.code ?? "REDACTED"}</div>
{r.description && <div className="text-[#8896aa] max-w-[160px] truncate">{r.description}</div>}
</div>
</div>
))}
</div>
</Section>
);
}
/* ── EVIDENCE: signatures ─────────────────────────────────── */
if (isNonEmptyArray(fm.signatures_observed)) {
sections.push(
<Section key="sigs" title="Signatures" icon={<PenSquare size={12} className="text-[#bb6bd9]" />} count={fm.signatures_observed!.length}>
<div className="flex flex-wrap gap-2">
{fm.signatures_observed!.map((s, i) => (
<div key={i} className="flex items-center gap-2 border border-[rgba(187,107,217,0.32)] bg-[rgba(187,107,217,0.04)] rounded p-1.5">
<FmBboxThumb bbox={s.bbox} docId={pageCtx?.docId} pageNum={pageCtx?.pageNum} width={64} height={48} />
<div className="text-[10px] font-mono">
<div className="text-[#bb6bd9] font-semibold">{s.signer_inferred ?? "unknown signer"}</div>
<FmConfidence band={s.confidence_band} />
{s.notes && <div className="text-[#8896aa] max-w-[160px] truncate">{s.notes}</div>}
</div>
</div>
))}
</div>
</Section>
);
}
/* ── EVIDENCE: tables ─────────────────────────────────────── */
if (isNonEmptyArray(fm.tables_detected)) {
sections.push(
<Section key="tabs" title="Tables on this page" icon={<Table2 size={12} className="text-[#1e9eb5]" />} count={fm.tables_detected!.length}>
<div className="flex flex-wrap gap-2">
{fm.tables_detected!.map((t, i) => (
<div key={i} className="flex items-center gap-2 border border-[rgba(30,158,181,0.32)] bg-[rgba(30,158,181,0.04)] rounded p-1.5">
<FmBboxThumb bbox={t.bbox} docId={pageCtx?.docId} pageNum={pageCtx?.pageNum} width={64} height={48} />
<div className="text-[10px] font-mono">
{t.table_id ? <FmWikiLink target={`table/${t.table_id}`} size="xs" /> : <span className="text-[#1e9eb5]">inline table</span>}
<div className="text-[#8896aa] mt-1">
{t.col_count_estimate ?? "?"}×{t.row_count_estimate ?? "?"}
{t.spans_multi_page && <span className="text-[#f5c542] ml-1">multi-page</span>}
</div>
{t.headers_summary && <div className="text-[#5a6678] max-w-[160px] truncate">{t.headers_summary}</div>}
</div>
</div>
))}
</div>
</Section>
);
}
/* ── EVIDENCE: images detected ────────────────────────────── */
if (isNonEmptyArray(fm.images_detected)) {
sections.push(
<Section key="imgs" title="Images on this page" icon={<ImageIcon size={12} className="text-[#ffeb99]" />} count={fm.images_detected!.length}>
<div className="flex flex-wrap gap-2">
{fm.images_detected!.map((im, i) => (
<div key={i} className="flex items-center gap-2 border border-[rgba(255,235,153,0.32)] bg-[rgba(255,235,153,0.04)] rounded p-1.5">
<FmBboxThumb bbox={im.bbox} docId={pageCtx?.docId} pageNum={pageCtx?.pageNum} width={64} height={64} />
<div className="text-[10px] font-mono">
<div className="text-[#ffeb99] font-semibold">{im.image_type ?? "image"}</div>
{im.caption_ocr && <div className="text-[#8896aa] max-w-[160px]">{im.caption_ocr}</div>}
</div>
</div>
))}
</div>
</Section>
);
}
/* ── UAP OBSERVATION ──────────────────────────────────────── */
if (fm.uap_observation_fields) {
const u = fm.uap_observation_fields;
sections.push(
<Section key="uap" title="UAP Observation" icon={<Disc3 size={12} className="text-[#ff3344]" />}>
<div className="flex flex-wrap gap-2">
{u.shape && <FmChip color="red" icon={<Disc3 size={9} />} label={`shape: ${u.shape}`} />}
{u.color && <FmChip color="red" label={`color: ${u.color}`} />}
{u.size_estimate && <FmChip color="red" label={`size: ${u.size_estimate}`} />}
{u.altitude_ft !== null && u.altitude_ft !== undefined && <FmChip color="cyan" label={`alt: ${u.altitude_ft} ft`} />}
{u.speed_kts !== null && u.speed_kts !== undefined && <FmChip color="cyan" label={`speed: ${u.speed_kts} kts`} />}
{u.bearing_deg !== null && u.bearing_deg !== undefined && <FmChip color="cyan" label={`bearing: ${u.bearing_deg}°`} />}
{u.distance_nm !== null && u.distance_nm !== undefined && <FmChip color="cyan" label={`dist: ${u.distance_nm} nm`} />}
{u.duration_seconds && <FmChip color="cyan" label={`duration: ${u.duration_seconds}s`} />}
{u.coordinates && <FmCoordinates lat={u.coordinates.lat ?? undefined} lon={u.coordinates.lon ?? undefined} raw={u.coordinates.raw_text} />}
</div>
</Section>
);
}
/* ── UAP-OBJECT specific fields (entity-level) ────────────── */
if (fm.entity_class === "uap_object") {
const u = fm;
sections.push(
<Section key="uapent" title="Object profile" icon={<Disc3 size={12} className="text-[#ff3344]" />}>
<div className="flex flex-wrap gap-2">
{u.shape && <FmChip color="red" label={`shape: ${u.shape}`} />}
{u.color && <FmChip color="red" label={`color: ${u.color}`} />}
{u.size_estimate_m && <FmChip color="red" label={`size: ${u.size_estimate_m.min ?? "?"}${u.size_estimate_m.max ?? "?"} m`} />}
{u.altitude_ft && <FmChip color="cyan" label={`alt: ${u.altitude_ft.min ?? "?"}${u.altitude_ft.max ?? "?"} ft`} />}
{u.speed_kts && <FmChip color="cyan" label={`speed: ${u.speed_kts.min ?? "?"}${u.speed_kts.max ?? "?"} kts`} />}
{isNonEmptyArray(u.maneuver_descriptors) && u.maneuver_descriptors!.map((m, i) => <FmChip key={i} color="violet" label={m} />)}
{isNonEmptyArray(u.features) && u.features!.map((f, i) => <FmChip key={`f${i}`} color="amber" label={f} />)}
{u.confidence_band_overall && <FmConfidence band={u.confidence_band_overall} />}
</div>
{u.observed_in_event && (
<div className="mt-2 text-[10px] text-[#5a6678] font-mono">observed in: <FmWikiLink target={u.observed_in_event} size="xs" /></div>
)}
</Section>
);
}
/* ── LOCATION specific ────────────────────────────────────── */
if (fm.entity_class === "location") {
sections.push(
<Section key="loc" title="Location" icon={<MapPin size={12} className="text-[#3fde6a]" />}>
<div className="flex flex-wrap gap-2 items-center">
{fm.location_type && <FmChip color="green" label={fm.location_type} />}
{fm.country && <FmChip color="soft" label={Array.isArray(fm.country) ? fm.country.join(", ") : fm.country} />}
{fm.region && <FmChip color="soft" label={fm.region} />}
{fm.coordinates && (
<FmCoordinates lat={fm.coordinates.lat ?? undefined} lon={fm.coordinates.lon ?? undefined} raw={fm.coordinates.raw_text} />
)}
{fm.parent_location && <FmWikiLink target={fm.parent_location} size="xs" />}
</div>
</Section>
);
}
/* ── EVENT specific ───────────────────────────────────────── */
if (fm.entity_class === "event") {
sections.push(
<Section key="evt" title="Event" icon={<CalendarRange size={12} className="text-[#ffa500]" />}>
<div className="flex flex-wrap gap-2 items-center">
{fm.event_class && <FmChip color="amber" label={fm.event_class} />}
{fm.date_start && <FmChip color="amber" label={`from: ${fm.date_start}`} />}
{fm.date_end && fm.date_end !== fm.date_start && <FmChip color="amber" label={`to: ${fm.date_end}`} />}
{fm.date_confidence && <FmConfidence band={fm.date_confidence} />}
{fm.primary_location && <FmWikiLink target={fm.primary_location} size="xs" />}
{isNonEmptyArray(fm.observers) && fm.observers!.map((o, i) => <FmWikiLink key={`o${i}`} target={o} size="xs" />)}
{isNonEmptyArray(fm.uap_objects) && fm.uap_objects!.map((u, i) => <FmWikiLink key={`u${i}`} target={u} size="xs" />)}
</div>
</Section>
);
}
/* ── PERSON specific ──────────────────────────────────────── */
if (fm.entity_class === "person") {
const dates = fm.dates ?? {};
sections.push(
<Section key="psn" title="Person" icon={<Users size={12} className="text-[#ff6ec7]" />}>
<div className="flex flex-wrap gap-2 items-center">
{fm.primary_role && <FmChip color="violet" label={fm.primary_role} />}
{fm.primary_organization && <FmChip color="amber" label={fm.primary_organization} />}
{isNonEmptyArray(fm.roles) && fm.roles!.map((r, i) => <FmChip key={i} color="violet" label={r} />)}
{(dates.born || dates.died) && (
<FmChip color="soft" label={`${dates.born ?? "?"}${dates.died ?? "?"}`} />
)}
</div>
</Section>
);
}
/* ── ORGANIZATION specific ────────────────────────────────── */
if (fm.entity_class === "organization") {
sections.push(
<Section key="org" title="Organization" icon={<Building2 size={12} className="text-[#ff8a4d]" />}>
<div className="flex flex-wrap gap-2 items-center">
{fm.organization_type && <FmChip color="amber" label={fm.organization_type} />}
{fm.country && <FmChip color="soft" label={Array.isArray(fm.country) ? fm.country.join(", ") : fm.country} />}
{fm.founded && <FmChip color="soft" label={`founded: ${fm.founded}`} />}
</div>
</Section>
);
}
/* ── CONCEPT/VEHICLE/OPERATION quick chips ────────────────── */
if (fm.entity_class === "concept") {
sections.push(
<Section key="cncpt" title="Concept" icon={<BookOpen size={12} className="text-[#06d6a0]" />}>
<div className="flex flex-wrap gap-2 items-center">
{fm.concept_class && <FmChip color="green" label={fm.concept_class} />}
{fm.domain && <FmChip color="soft" label={fm.domain} />}
</div>
{fm.definition_short && <p className="text-sm text-[#c8d4e6] mt-2">{fm.definition_short}</p>}
{fm.definition_short_pt_br && <p className="text-xs text-[#8896aa] mt-1 italic">{fm.definition_short_pt_br}</p>}
</Section>
);
}
/* ── ENRICHMENT ───────────────────────────────────────────── */
if (fm.enrichment_status || isNonEmptyArray(fm.external_sources)) {
sections.push(
<Section key="enr" title="Enrichment" icon={<Stamp size={12} />}>
<div className="flex items-center gap-2 mb-1">
<FmEnrichmentBadge status={fm.enrichment_status} />
{fm.last_enriched && <FmTimestamp value={fm.last_enriched} label="last" />}
</div>
<FmExternalSources sources={fm.external_sources} />
</Section>
);
}
/* ── FLAGS ────────────────────────────────────────────────── */
if (isNonEmptyArray(fm.flags)) {
sections.push(
<Section key="flags" title="Flags" icon={<AlertTriangle size={12} className="text-[#f5c542]" />}>
<div className="flex flex-wrap gap-1">
{fm.flags!.map((f, i) => <FmFlag key={i} flag={f} />)}
</div>
</Section>
);
}
/* ── TIMESTAMPS (compact footer) ──────────────────────────── */
const ts: React.ReactNode[] = [];
if (fm.last_ingest) ts.push(<FmTimestamp key="li" value={fm.last_ingest} label="ingest" />);
if (fm.last_lint) ts.push(<FmTimestamp key="ll" value={fm.last_lint} label="lint" />);
if (fm.last_enriched && !fm.external_sources) ts.push(<FmTimestamp key="le" value={fm.last_enriched} label="enriched" />);
return (
<div className="space-y-5">
{sections}
{ts.length > 0 && (
<div className="flex flex-wrap gap-3 pt-2 border-t border-[rgba(0,255,156,0.08)]">{ts}</div>
)}
</div>
);
}