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>
152 lines
5.2 KiB
TypeScript
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>
|
|
);
|
|
}
|