disclosure-bureau/web/components/entity-relations.tsx
Luiz Gustavo a7e9dce6d2 rebuild entity layer from Sonnet-vision reextract pipeline
Add reextract pipeline (scripts/reextract/) that rebuilds doc-level entity
JSON from Sonnet-vision chunks via Opus, replacing the noisy per-page
extraction. Add synthesize scripts to regenerate wiki/entities from the 116
_reextract.json (30), aggregate missing page.md from chunks (31), and reprocess
805 pages the doc-rebuilder agent dropped on context overflow (32). Add
maintain scripts 43-56 for chunk-page sync, dedup, generic-entity marking, and
typed relation extraction.

Web: wire relations API + entity-relations component; entity/timeline/doc
pages consume the rebuilt layer.

Note: raw/, processing/, wiki/ remain gitignored (bulk data managed
separately); the 116 reextract JSONs and 7,798 rebuilt entity files live on
disk only. The 27 curated anchor events under wiki/entities/events/ are
preserved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 12:20:24 -03:00

152 lines
5.2 KiB
TypeScript

/**
* EntityRelations — typed relations panel for an entity page.
*
* Renders semantically-typed edges (Person witnessed Event, Event documented_in
* Document, etc.) grouped by relation_type and direction, instead of the
* noisy co-mention list.
*/
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
interface Relation {
source_class: string;
source_id: string;
relation_type: string;
target_class: string;
target_id: string;
evidence_ref: string | null;
confidence: string;
}
interface ApiResponse {
outgoing: Relation[];
incoming: Relation[];
error?: string;
}
const TYPE_LABEL_PT: Record<string, { out: string; in: string }> = {
witnessed: { out: "testemunhou", in: "foi testemunhado por" },
occurred_at: { out: "ocorreu em", in: "foi local de" },
involves_uap: { out: "envolve UAP", in: "observado em" },
documented_in: { out: "documentado em", in: "documenta" },
authored: { out: "autoria de", in: "autor:" },
signed: { out: "assinou", in: "assinado por" },
mentioned_by: { out: "mencionado em", in: "menciona" },
employed_by: { out: "trabalhou em", in: "empregou" },
operated_by: { out: "operada por", in: "operou" },
investigated: { out: "investigou", in: "investigado por" },
commanded: { out: "comandou", in: "comandado por" },
related_to: { out: "relacionado a", in: "relacionado por" },
similar_to: { out: "similar a", in: "similar de" },
precedes: { out: "precede", in: "precedido por" },
follows: { out: "segue", in: "seguido por" },
};
const ENTITY_FOLDER: Record<string, string> = {
person: "people",
organization: "organizations",
location: "locations",
event: "events",
uap_object: "uap-objects",
vehicle: "vehicles",
operation: "operations",
concept: "concepts",
};
function entityHref(cls: string, id: string): string {
if (cls === "document") return `/d/${id}`;
const folder = ENTITY_FOLDER[cls] ?? cls;
return `/e/${folder}/${id}`;
}
export function EntityRelations({
entityClass,
entityId,
}: {
entityClass: string;
entityId: string;
}) {
const [data, setData] = useState<ApiResponse | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let aborted = false;
setLoading(true);
fetch(`/api/relations?class=${entityClass}&id=${encodeURIComponent(entityId)}`)
.then((r) => r.json())
.then((j) => { if (!aborted) { setData(j); setLoading(false); } })
.catch(() => { if (!aborted) { setData({ outgoing: [], incoming: [] }); setLoading(false); } });
return () => { aborted = true; };
}, [entityClass, entityId]);
if (loading) {
return <div className="font-mono text-xs text-[#5a6678]">carregando relações</div>;
}
if (!data || (data.outgoing.length === 0 && data.incoming.length === 0)) {
return (
<div className="font-mono text-xs text-[#5a6678] italic">
sem relações tipadas extraídas para esta entidade.
</div>
);
}
// Group by relation_type for outgoing and incoming separately
const groupOut: Record<string, Relation[]> = {};
for (const r of data.outgoing) (groupOut[r.relation_type] ||= []).push(r);
const groupIn: Record<string, Relation[]> = {};
for (const r of data.incoming) (groupIn[r.relation_type] ||= []).push(r);
const renderGroup = (type: string, list: Relation[], dir: "out" | "in") => {
const label = TYPE_LABEL_PT[type]?.[dir] ?? type;
return (
<div key={`${dir}-${type}`} className="mb-3">
<div className="font-mono text-[10px] uppercase tracking-widest text-[#7fdbff] mb-1">
{label} · <span className="text-[#5a6678]">{list.length}</span>
</div>
<ul className="space-y-0.5 text-xs">
{list.slice(0, 12).map((r, i) => {
const otherClass = dir === "out" ? r.target_class : r.source_class;
const otherId = dir === "out" ? r.target_id : r.source_id;
return (
<li key={i} className="font-mono text-[#c8d4e6]">
<Link
href={entityHref(otherClass, otherId)}
className="hover:text-[#00ff9c] truncate"
title={`${otherClass}/${otherId}`}
>
<span className="text-[#5a6678]">[{otherClass[0]}]</span> {otherId}
</Link>
</li>
);
})}
{list.length > 12 && (
<li className="font-mono text-[10px] text-[#5a6678]"> +{list.length - 12}</li>
)}
</ul>
</div>
);
};
return (
<div className="space-y-4">
{Object.keys(groupOut).length > 0 && (
<section>
<h3 className="font-mono text-xs uppercase tracking-widest text-[#00ff9c] mb-2">
Relações desta entidade
</h3>
{Object.entries(groupOut).map(([t, list]) => renderGroup(t, list, "out"))}
</section>
)}
{Object.keys(groupIn).length > 0 && (
<section>
<h3 className="font-mono text-xs uppercase tracking-widest text-[#ffa500] mb-2">
Entidades que apontam para esta
</h3>
{Object.entries(groupIn).map(([t, list]) => renderGroup(t, list, "in"))}
</section>
)}
</div>
);
}