disclosure-bureau/web/app/d/[docId]/page.tsx
Luiz Gustavo d4a2e4f51e
Some checks failed
CI / Web — typecheck + lint + build (push) Failing after 37s
CI / Scripts — Python smoke (push) Failing after 5s
CI / Web — npm audit (push) Failing after 40s
CI / Retrieval — golden set (Recall@5 + MRR) (push) Failing after 4s
W3.10: clickable detective tiles + quick-launch form + doc bureau panel
Builds on top of W3.9 to turn the homepage Bureau from a read-only
dashboard into a working command center.

UI improvements (web/components/bureau-snapshot.tsx):
  - Detective tiles are now <Link>s — each navigates to its primary
    artefact section in /bureau (Holmes→#hypotheses, Locard→#evidence,
    Dupin→#contradictions, Schneier→#hypotheses, Poirot→#witnesses,
    Taleb→#outliers, Tetlock→#hypotheses, Case-Writer→#reports). Hover
    bg matches the detective's tone color.
  - <QuickLaunch /> form inserted right under the tiles.

New <QuickLaunch /> client component:
  - Detective dropdown (7 active kinds; evidence_chain not yet exposed
    here since it needs a doc_id better picked from the doc page).
  - Single input swaps placeholder + aria-label by kind: question for
    Holmes, topic for Dupin/Taleb/Case-Writer, hypothesis_id for
    Schneier/Tetlock, person_id for Poirot.
  - Submits to POST /api/bureau/launch and redirects to /jobs/[id]
    via the next.js router.
  - Loading state ("queueing…") + error display inline.

POST /api/bureau/launch (web/app/api/bureau/launch/route.ts):
  - Same 8-kind validator as the chat tool's request_investigation.
  - Auth required when Supabase is configured (triggered_by = user:email).
  - Returns { job_id, kind, detective, status_url, eta_seconds }.

DocBureauPanel on /d/[docId] (web/components/doc-bureau-panel.tsx):
  - Server component inserted between the doc header and
    AnomalyHighlights.
  - Surfaces every bureau artefact that touches the doc:
    · Evidence whose source_page_id starts with docId/p
    · Hypotheses citing any of those evidence_ids
    · Contradictions whose chunks[] has any item with this doc_id
    · Gaps/outliers with scope.doc_id == docId
    · Case reports whose markdown body references docId (filesystem scan)
  - Empty state shows "Investigation Bureau — untouched" with a CTA
    linking back to the homepage to launch the first investigation.
  - When non-empty, header counts total artefacts + links to /bureau
    for the full view.

Metadata (web/app/layout.tsx):
  - description rewritten from "Investigative wiki of the US Department
    of War UAP/UFO archive (war.gov/ufo)" to one that names the bureau
    + the 8 detectives. Affects SERP previews + social-card defaults.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:33:00 -03:00

164 lines
6.1 KiB
TypeScript

/**
* /d/<docId> — Document view (Sonnet 4.6 agentic chunks v0.2.0, the only view).
*
* Server component loads chunks from disk; client component <DocRendererV2>
* provides language + flow/paged toggles, image crops on-demand, table CSVs.
*/
import Link from "next/link";
import { notFound } from "next/navigation";
import { readChunksByPage, readIndex, hasChunks, readReadingVersion } from "@/lib/chunks";
import { readDocument } from "@/lib/wiki";
import { pickPitch } from "@/lib/doc-summary";
import { getLocale } from "@/components/locale-toggle";
import { AuthBar } from "@/components/auth-bar";
import { ChatBubble } from "@/components/chat-bubble";
import { DocReadingView } from "@/components/doc-reading-view";
import { AnomalyHighlights, type AnomalyFlag } from "@/components/anomaly-highlights";
import { DocBureauPanel } from "@/components/doc-bureau-panel";
import { MarkdownBody } from "@/components/markdown-body";
export const dynamic = "force-dynamic";
export default async function DocPage({
params,
}: {
params: Promise<{ docId: string }>;
}) {
const { docId } = await params;
const locale = await getLocale();
if (!(await hasChunks(docId))) {
return (
<main className="min-h-screen p-6 md:p-10 max-w-4xl mx-auto">
<div className="flex items-start justify-between gap-4 mb-6">
<Link href="/" className="font-mono text-xs text-[#7fdbff] hover:text-[#00ff9c]">
home
</Link>
<AuthBar />
</div>
<div className="border border-[rgba(255,165,0,0.30)] bg-[rgba(255,165,0,0.05)] rounded p-6">
<h1 className="font-mono text-lg text-[#ffa500] mb-2"> Documento ainda não processado</h1>
<p className="text-[#c8d4e6] text-sm">
Este documento ainda não foi indexado.
</p>
<p className="font-mono text-xs text-[#5a6678] mt-4">doc_id: {docId}</p>
</div>
</main>
);
}
const [idx, byPage, doc, reading] = await Promise.all([
readIndex(docId),
readChunksByPage(docId),
readDocument(docId),
readReadingVersion(docId),
]);
if (!idx) notFound();
const ordered: Array<[number, typeof byPage extends Map<number, infer V> ? V : never]> =
Array.from(byPage.entries()).sort((a, b) => a[0] - b[0]);
const pitch = pickPitch(
doc?.fm as Record<string, unknown> | undefined,
locale === "en" ? "en" : "pt",
);
// Histogram chunk types
const typeCounts = new Map<string, number>();
for (const entry of idx.chunks ?? []) {
typeCounts.set(entry.type, (typeCounts.get(entry.type) || 0) + 1);
}
const topTypes = Array.from(typeCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 6);
// Count UFO/cryptid anomalies across chunks + collect flags for the highlight panel
let imageCount = 0;
const ufoFlags: AnomalyFlag[] = [];
const cryptidFlags: AnomalyFlag[] = [];
for (const [page, chunks] of byPage) {
for (const c of chunks) {
if (c.fm.ufo_anomaly_detected)
ufoFlags.push({
chunk_id: c.fm.chunk_id,
page,
type: c.fm.ufo_anomaly_type ?? null,
rationale: c.fm.ufo_anomaly_rationale ?? null,
});
if (c.fm.cryptid_anomaly_detected)
cryptidFlags.push({
chunk_id: c.fm.chunk_id,
page,
type: c.fm.cryptid_anomaly_type ?? null,
rationale: c.fm.cryptid_anomaly_rationale ?? null,
});
if (c.fm.type === "image") imageCount++;
}
}
const ufoCount = ufoFlags.length;
const cryptidCount = cryptidFlags.length;
const classification = (doc?.fm.highest_classification as string) ?? "—";
const collection = (doc?.fm.collection as string) ?? "—";
return (
<main className="min-h-screen p-6 md:p-10 max-w-5xl mx-auto">
<div className="flex items-start justify-between gap-4 mb-6">
<Link href="/" className="font-mono text-xs text-[#7fdbff] hover:text-[#00ff9c]">
home
</Link>
<AuthBar />
</div>
<header className="mb-6 pb-6 border-b border-[rgba(0,255,156,0.32)]">
<div className="font-mono text-[10px] text-[#5a6678] tracking-widest uppercase mb-2 flex items-center gap-2 flex-wrap">
<span className="px-2 py-0.5 border border-[#ff3344] text-[#ff3344] rounded">
{classification}
</span>
<span>· {collection}</span>
<span>· doc_id: <span className="text-[#7fdbff]">{docId}</span></span>
</div>
<h1 className="font-mono text-3xl text-[#00ff9c] mb-3">
{(doc?.fm.canonical_title as string) ?? docId}
</h1>
{pitch && (
<div className="mt-4 mb-4 p-4 border-l-4 border-[#7fdbff] bg-[rgba(127,219,255,0.04)]">
<div className="text-[15px] leading-relaxed text-[#c8d4e6] markdown-body">
<MarkdownBody>{pitch}</MarkdownBody>
</div>
</div>
)}
<div className="flex flex-wrap items-center gap-4 mt-4 font-mono text-xs text-[#8896aa]">
<span><span className="text-[#7fdbff]">{idx.total_pages}</span> páginas</span>
<span><span className="text-[#00ff9c]">{idx.total_chunks}</span> trechos</span>
{imageCount > 0 && <span><span className="text-[#a78bfa]">{imageCount}</span> imagens</span>}
{ufoCount > 0 && <span><span className="text-[#ff3344]">🛸 {ufoCount}</span> UAP flags</span>}
{cryptidCount > 0 && <span><span className="text-[#9b5de5]">{cryptidCount}</span> cryptid</span>}
</div>
{topTypes.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5 font-mono text-[10px]">
{topTypes.map(([t, n]) => (
<span
key={t}
className="px-2 py-0.5 border border-[rgba(127,219,255,0.20)] text-[#8896aa] rounded"
>
{t} <span className="text-[#7fdbff]">{n}</span>
</span>
))}
</div>
)}
</header>
<DocBureauPanel docId={docId} />
<AnomalyHighlights docId={docId} ufo={ufoFlags} cryptid={cryptidFlags} />
<DocReadingView docId={docId} reading={reading} chunksByPage={ordered} />
<ChatBubble context={{ doc_id: docId }} />
</main>
);
}