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>
135 lines
4.8 KiB
TypeScript
135 lines
4.8 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|