W0 — security hardening (5 fixes verified live on disclosure.top)
- middleware: gate /api/admin/* same as /admin/* (F1)
- imgproxy: tighten LOCAL_FILESYSTEM_ROOT from / to /var/lib/storage (F2)
- studio: real basic-auth label (bcrypt hash, middleware reference) (F3)
- relations: ENABLE ROW LEVEL SECURITY + public SELECT policy (F4)
- migration 0003: fold is_searchable + hybrid_search update into canonical (TD#2)
W1 — observability + resilience + autocomplete
- studio: HOSTNAME=0.0.0.0 so Next.js binds on loopback for healthcheck
- compose: PG_POOL_MAX=20, CLAUDE_CODE_OAUTH_TOKEN gated by separate env
- claude-code.ts: subprocess timeout configurable (CLAUDE_CODE_TIMEOUT_MS)
- openrouter.ts: retry with exponential backoff + Retry-After + in-memory
circuit breaker (promotes FALLBACK after CB_THRESHOLD failures)
- lib/logger.ts: pino logger (NDJSON prod / pretty dev) + withRequest helper
- middleware: mints correlation_id, stamps x-correlation-id response header,
emits structured http_request log per /api/* call
- messages/route.ts: switch to structured logger
- 60_meili_index.py: push documents + chunks into Meilisearch
- /api/search/autocomplete: parallel meili search (docs + chunks), 5-8ms p50
- search-autocomplete.tsx: debounced dropdown wired into search-panel
W1.2 — Glitchtip + Forgejo self-hosted
- compose: glitchtip-redis + glitchtip-web + glitchtip-worker (v4.2)
- compose: forgejo + forgejo-runner (server v9, runner v6) with group_add=988
- @sentry/nextjs SDK wired (instrumentation.ts + sentry.{client,server}.config.ts)
- /api/admin/throw smoke endpoint (gated by W0-F1 middleware)
- Synthetic event ingestion verified at glitchtip.disclosure.top
- forgejo.disclosure.top up, repo discadmin/disclosure-bureau created,
runner registered (labels: ubuntu-latest, docker)
- .forgejo/workflows/ci.yml: typecheck + lint + build + npm audit + python
syntax + compose validation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
161 lines
6 KiB
TypeScript
161 lines
6 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 { 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>
|
|
|
|
<AnomalyHighlights docId={docId} ufo={ufoFlags} cryptid={cryptidFlags} />
|
|
|
|
<DocReadingView docId={docId} reading={reading} chunksByPage={ordered} />
|
|
|
|
<ChatBubble context={{ doc_id: docId }} />
|
|
</main>
|
|
);
|
|
}
|