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

436 lines
23 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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>
);
}