// The SHIPPED shape of the AI-digest corpus: a per-channel paginated page tree // at /digests//{manifest,page-NNNN}.json, mirroring common/lib/manifest.ts // (transcripts) and common/lib/posts.ts (posts). // // This is deliberately NOT DigestRecord (common/lib/digest.ts). That type is the // on-disk sidecar the generator owns: two files per video, machine output plus a // human shadow, carrying operator telemetry. What ships is the COMPOSED result // of effectiveDigest() — human overrides applied, suppressed items dropped, // sorted — which is the only version a reader should ever see. // // Two things are deliberately absent from the shipped record: // // warnings[] operator telemetry for the editor panel and the Phase 11a review // queue. A reader has no use for "the model proposed 13 chapters // that were clamped away"; shipping it would put the corpus's // failures in front of the audience rather than the operator. // history[] the regeneration audit trail. Same reasoning, plus it is the // single largest field in a mature sidecar. // // SPARSE BY DESIGN. A channel's manifest lists only the videos that actually // have a digest — 102 of ~76,000 at the time of writing. `slugToPage` is // therefore also the existence check: a videoId absent from it has no digest, // which is what lets the viewer decide whether to render its Digest control // without a per-video fetch (the export/app/lib/duplicates.ts hasDuplicates() // idiom). A channel with zero digests gets no manifest at all. import type { DecidedBy, DigestDerivedFrom, DigestProvenance, } from "./digest"; import { pageFileName } from "./manifest"; // v1: the initial shipped shape. export const DIGESTS_MANIFEST_VERSION = 1; // One page-shard name for every layer; see lib/manifest.ts. export const digestPageFileName = pageFileName; // A titled moment. `start` is SECONDS and is what a seek uses: the parser has // already snapped it to a real cue boundary. `clock` is the raw HH:MM:SS string // the model emitted, carried for auditing — NEVER re-parse it to seek, or a // model that miscounted gets to move the playhead. export type DigestPageChapter = { id: string; start: number; clock: string; title: string; // "human" when an override replaced or added this item, so the viewer can be // honest about which chapters a person wrote. decidedBy: DecidedBy; }; export type DigestPageTag = { id: string; tag: string; decidedBy: DecidedBy; }; // One video's shipped digest — the unit a page file holds an array of. export type VideoDigest = { // "channelSlug/videoId", matching TranscriptDetail.slug. slug: string; // The bare video id, matching the manifest's slugToPage key. id: string; chapters: DigestPageChapter[]; tags: DigestPageTag[]; // Newest section generatedAt in this record (ISO-8601), or "" when no section // carries one. THE PER-ENTRY VERSION: digests are regenerated in place, so a // client's cached copy stays structurally valid while going stale, and a // structure sniff (what transcriptStore.ts gets away with) cannot detect that. // The client cache compares this field and treats a mismatch as a miss — // otherwise a corrected digest never reaches a returning reader. generatedAt: string; // Per-SECTION provenance, because one video can legitimately hold chapters // from the local lane and tags from the metered one. provenance: { chapters?: DigestProvenance; tags?: DigestProvenance; }; // Set when this digest was generated for a DIFFERENT video (a duplicate // cluster's canonical member) and shared onto this one. Presenting a borrowed // digest as native is the failure mode that looks like success: every chapter // reads plausibly while describing another upload. The viewer renders this. derivedFrom?: DigestDerivedFrom; }; export type DigestPage = VideoDigest[]; export type ChannelDigestsManifest = { version: number; channelSlug: string; pageCount: number; maxPageBytes: number; generatedAt: string; // videoId -> page index. Only DIGESTED videos appear; see the sparse note above. slugToPage: Record; // Content hash of each page, indexed by page number — the CACHE VERSION. // // A client cannot know whether its cached copy of one video's digest is // current without something to compare, and the record's own generatedAt is // only legible once the page has been fetched (which is the cost the cache // exists to avoid). The build already computes these hashes for its own // page-skip logic, so surfacing them is free and gives exact per-page // versioning: any regeneration, retitle or suppression changes the hash of // the page it lands on and nothing else. // // Optional so a manifest written without it still parses; a client that // finds it absent simply treats every cached entry as a miss. pageHashes?: string[]; }; // --- site level ------------------------------------------------------------ // The per-SITE manifest at /digests/manifest.json: which member channels carry // digests, and how many. Mirrors the site posts manifest, and exists for the // same reason — compose-site.ts reads it to populate corpus.json's per-channel // digests pointer without walking every channel's page tree. export const SITE_DIGESTS_MANIFEST_VERSION = 1; export type DigestsChannelEntry = { name: string; slug: string; digestCount: number; groupId?: string; }; export type DigestsManifest = { version: number; channels: DigestsChannelEntry[]; totalCount: number; generatedAt: string; siteId?: string; }; // Whether a manifest covers a given video. The whole existence check, in one // place, so the viewer's control-gating and the fetch path agree on what // "has a digest" means. export function manifestHasDigest( manifest: ChannelDigestsManifest | null, videoId: string, ): boolean { return manifest?.slugToPage[videoId] !== undefined; } // The newest generatedAt across a record's sections — the value that lands in // VideoDigest.generatedAt. Pure, so both the builder and any test can call it. export function newestGeneratedAt(provenance: { chapters?: Pick; tags?: Pick; }): string { const stamps = [ provenance.chapters?.generatedAt, provenance.tags?.generatedAt, ].filter((s): s is string => typeof s === "string" && s !== ""); if (stamps.length === 0) return ""; // ISO-8601 sorts lexicographically, so no Date parsing is needed. return stamps.reduce((a, b) => (a > b ? a : b)); }