disclosure-bureau/web/app/api/timeline/route.ts
guto 4459bd17e4 phase-0: kill stubs, ship 20 curated anchor events, configure SMTP
- scripts/03-dedup-entities.py: stop emitting placeholder narrative ("Stub. Will
  be enriched in Phase 7"); write summary_status=none + null fields instead.
- scripts/maintain/41_strip_stubs.py: idempotent migration that cleaned the
  22,096 entity .md files (now zero stub strings in wiki/).
- scripts/synthesize/01_anchor_events.py: curated 20 anchor UAP events
  (Roswell, Nimitz Tic-Tac, Phoenix Lights, Operação Prato, AATIP, etc.) with
  bilingual Holmes-Watson narrative via claude -p --model sonnet
  (CLAUDE_CODE_OAUTH_TOKEN). All summary_status=curated, confidence=high.
- web/api/timeline + timeline-view: filter narrative-less events by default,
  render "curado" badge for hand-vetted ones, drop the date display alone.
- CLAUDE-schema-full.md: document the summary_status enum and the four states.
- docker-compose.yml: SMTP_HOST=mail.spacemail.com configured;
  GOTRUE_MAILER_AUTOCONFIRM flipped to false (real email confirmation working).
- .nirvana/outputs/.../systems-atelier/: 5 deliverables of the architecture
  audit that produced this roadmap.
2026-05-18 00:44:17 -03:00

135 lines
4.8 KiB
TypeScript

/**
* /api/timeline?from=1940&to=2026&class=event&limit=200
*
* Returns events sorted by date_start from wiki/entities/events/*.md frontmatter.
* No DB required — pure filesystem read of YAML frontmatter.
*
* Optional filters:
* - from / to: ISO dates or year strings (e.g. "1947" or "1947-06-24")
* - class: "event" | "uap_object" | "operation" (default: event)
* - limit: 1..500 (default 200)
* - q: substring match in canonical_name or narrative_summary
*/
import fs from "node:fs/promises";
import path from "node:path";
import matter from "gray-matter";
import { WIKI } from "@/lib/wiki";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const CLASS_FOLDER: Record<string, string> = {
event: "events",
uap_object: "uap-objects",
operation: "operations",
};
interface TimelineEntry {
entity_class: string;
entity_id: string;
canonical_name: string;
date_start: string | null;
date_end: string | null;
primary_location?: string | null;
narrative_summary?: string | null;
summary_status: "none" | "synthesized" | "curated" | "red_teamed";
total_mentions?: number;
href: string;
}
function json(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json", "cache-control": "public, max-age=60" },
});
}
function pickDate(fm: Record<string, unknown>): string | null {
const cand =
fm.date_start ?? fm.date ?? fm.event_date ?? fm.observation_date ?? fm.start_date ?? null;
if (!cand) return null;
return String(cand).trim();
}
function dateSortable(s: string | null): string {
if (!s) return "9999";
// Pad year-only ("1947" → "1947-00-00") and accept "1947-06-24"
const m = s.match(/^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?/);
if (!m) return s;
return `${m[1]}-${m[2] ?? "00"}-${m[3] ?? "00"}`;
}
export async function GET(req: Request) {
const u = new URL(req.url);
const cls = (u.searchParams.get("class") ?? "event") as keyof typeof CLASS_FOLDER;
const folder = CLASS_FOLDER[cls];
if (!folder) return json({ error: "bad_class", class: cls }, 400);
const from = u.searchParams.get("from") ?? "";
const to = u.searchParams.get("to") ?? "";
const q = (u.searchParams.get("q") ?? "").toLowerCase().trim();
const limit = Math.min(Math.max(Number(u.searchParams.get("limit") ?? 200), 1), 500);
// Default: only show events with curated/synthesised narrative — never stubs.
// Opt-in `?include_unsynthesized=1` returns everything (admin / debug).
const includeUnsynthesized = u.searchParams.get("include_unsynthesized") === "1";
const dir = path.join(WIKI, "entities", folder);
let files: string[] = [];
try {
files = (await fs.readdir(dir)).filter((f) => f.endsWith(".md"));
} catch {
return json({ entries: [], count: 0 });
}
const entries: TimelineEntry[] = [];
for (const f of files) {
try {
const raw = await fs.readFile(path.join(dir, f), "utf-8");
const parsed = matter(raw);
const fm = parsed.data as Record<string, unknown>;
const date_start = pickDate(fm);
if (!date_start) continue;
const sortable = dateSortable(date_start);
if (from && sortable < dateSortable(from)) continue;
if (to && sortable > dateSortable(to)) continue;
const canonical = String(fm.canonical_name ?? f.replace(/\.md$/, ""));
const narrativeRaw = fm.narrative_summary;
const narrative = typeof narrativeRaw === "string" ? narrativeRaw : "";
const statusRaw = String(fm.summary_status ?? (narrative ? "synthesized" : "none"));
const summary_status = (
["none", "synthesized", "curated", "red_teamed"].includes(statusRaw)
? statusRaw
: "none"
) as TimelineEntry["summary_status"];
// Default: hide events without a real narrative.
if (!includeUnsynthesized && summary_status === "none") continue;
if (q && !canonical.toLowerCase().includes(q) && !narrative.toLowerCase().includes(q)) {
continue;
}
entries.push({
entity_class: cls,
entity_id: f.replace(/\.md$/, ""),
canonical_name: canonical,
date_start,
date_end: (fm.date_end as string) ?? null,
primary_location: (fm.primary_location as string) ?? null,
narrative_summary: narrative ? narrative.slice(0, 280) : null,
summary_status,
total_mentions: typeof fm.total_mentions === "number" ? fm.total_mentions : undefined,
href: `/e/${folder}/${f.replace(/\.md$/, "")}`,
});
} catch {
/* skip malformed */
}
}
entries.sort((a, b) => dateSortable(a.date_start).localeCompare(dateSortable(b.date_start)));
return json({
count: entries.length,
limit,
from: from || null,
to: to || null,
class: cls,
entries: entries.slice(0, limit),
});
}