disclosure-bureau/web/components/anomaly-highlights.tsx
Luiz Gustavo 55cac8a395
Some checks failed
CI / Web — typecheck + lint + build (push) Failing after 1m30s
CI / Scripts — Python smoke (push) Failing after 32s
CI / Web — npm audit (push) Failing after 37s
W0+W1+W1.2: security hardening, observability, autocomplete, glitchtip, forgejo CI
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>
2026-05-23 18:18:42 -03:00

135 lines
4.8 KiB
TypeScript
Raw 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.

/**
* AnomalyHighlights — prominent UAP / cryptid anomaly panel for the document
* page. The clean reading version is the default body, but the investigative
* "destaque" of every flagged passage must stay visible regardless of which
* view (reading or scan) is active. Identical type+rationale flags are grouped
* and each group links to the per-page scan where the anomaly was detected.
*/
import Link from "next/link";
export interface AnomalyFlag {
chunk_id: string;
page: number;
type: string | null;
rationale: string | null;
}
function clean(v: string | null): string | null {
const s = typeof v === "string" ? v.trim() : "";
return s && s.toLowerCase() !== "null" ? s : null;
}
interface Group {
type: string | null;
rationale: string | null; // shown only when the group has a single flag
count: number;
pages: number[];
}
// Group by anomaly type so the panel stays a scannable "destaque" overview.
// Per-passage rationale is kept only when a type has exactly one flag; the full
// per-chunk rationale remains available in the "trechos · scan original" view.
function groupFlags(flags: AnomalyFlag[]): Group[] {
const m = new Map<string, Group>();
for (const f of flags) {
const type = clean(f.type);
const rationale = clean(f.rationale);
const key = type ?? "anomalia";
const g = m.get(key) ?? { type, rationale, count: 0, pages: [] };
g.count += 1;
g.rationale = g.count === 1 ? rationale : null;
if (!g.pages.includes(f.page)) g.pages.push(f.page);
m.set(key, g);
}
return Array.from(m.values())
.map((g) => ({ ...g, pages: g.pages.sort((a, b) => a - b) }))
.sort((a, b) => b.count - a.count || a.pages[0] - b.pages[0]);
}
function pad(p: number): string {
return String(p).padStart(3, "0");
}
function PageChips({ docId, pages }: { docId: string; pages: number[] }) {
const shown = pages.slice(0, 14);
const extra = pages.length - shown.length;
return (
<span className="inline-flex flex-wrap gap-1 align-middle">
{shown.map((p) => (
<Link
key={p}
href={`/d/${docId}/p${pad(p)}`}
className="font-mono text-[10px] px-1.5 py-0.5 border border-[rgba(127,219,255,0.30)] text-[#7fdbff] rounded hover:border-[#00ff9c] hover:text-[#00ff9c]"
>
p{p}
</Link>
))}
{extra > 0 && <span className="font-mono text-[10px] text-[#5a6678]">+{extra}</span>}
</span>
);
}
export function AnomalyHighlights({
docId,
ufo,
cryptid,
}: {
docId: string;
ufo: AnomalyFlag[];
cryptid: AnomalyFlag[];
}) {
if (ufo.length === 0 && cryptid.length === 0) return null;
const ufoGroups = groupFlags(ufo);
const cryptidGroups = groupFlags(cryptid);
return (
<section className="mb-6 border border-[rgba(0,255,156,0.40)] bg-[rgba(0,255,156,0.05)] rounded p-4">
{ufo.length > 0 && (
<>
<h2 className="font-mono text-sm text-[#00ff9c] mb-3 flex items-center gap-2">
🛸 Anomalias UAP destacadas
<span className="text-[#5a6678]">
({ufo.length} {ufo.length === 1 ? "trecho" : "trechos"} · {ufoGroups.length}{" "}
{ufoGroups.length === 1 ? "tipo" : "tipos"})
</span>
</h2>
<ul className="space-y-2.5">
{ufoGroups.map((g, i) => (
<li key={i} className="text-sm text-[#c8d4e6] leading-relaxed">
<span className="font-mono text-[#00ff9c]">🛸 {g.type ?? "anomalia"}</span>
{g.count > 1 && (
<span className="font-mono text-[10px] text-[#5a6678]"> ×{g.count}</span>
)}
{g.rationale && <span className="text-[#c8d4e6]"> {g.rationale}</span>}{" "}
<PageChips docId={docId} pages={g.pages} />
</li>
))}
</ul>
</>
)}
{cryptid.length > 0 && (
<div className={ufo.length > 0 ? "mt-4 pt-4 border-t border-[rgba(155,93,229,0.25)]" : ""}>
<h2 className="font-mono text-sm text-[#9b5de5] mb-3 flex items-center gap-2">
👁 Anomalias cryptid destacadas
<span className="text-[#5a6678]">
({cryptid.length} {cryptid.length === 1 ? "trecho" : "trechos"})
</span>
</h2>
<ul className="space-y-2.5">
{cryptidGroups.map((g, i) => (
<li key={i} className="text-sm text-[#c8d4e6] leading-relaxed">
<span className="font-mono text-[#9b5de5]">👁 {g.type ?? "anomalia"}</span>
{g.count > 1 && (
<span className="font-mono text-[10px] text-[#5a6678]"> ×{g.count}</span>
)}
{g.rationale && <span className="text-[#c8d4e6]"> {g.rationale}</span>}{" "}
<PageChips docId={docId} pages={g.pages} />
</li>
))}
</ul>
</div>
)}
</section>
);
}