// The ONE place a digest freshness target is derived. // // Callers need to answer "would we regenerate this section?" — the batch runner // (to skip), countMissingDigests (to size a progress bar), and the digest // operation kind's resolveTarget, which the snapshot folds its work list from. // This is the ONE identity, and the snapshot no longer carries a second one: // it used to keep a `buckets.noDigest` list that asked only "does an // ai-digest.json with items exist?", so after any config change the Digest stage // read "All digested" while the batch reported the whole channel as stale. Two // counters that disagree by an entire channel is how a sweep reports "nothing // to do" on a corpus that needs redoing. // // The lane is a PARAMETER, never assumed. countMissingDigests used to resolve // settings.localAppId unconditionally, so a metered-lane job's progress target // was computed against the local engine's identity — and, once numCtx moved into // the recorded identity, mis-derived promptVariant from the local app's context // size as well. import type { Paths } from "../lib/paths"; import { getSettings } from "../lib/settings"; import { getDigestApp, type DigestApp, type DigestAppConfig } from "../lib/digestApps"; import { digestPromptVariant, type DigestFreshnessTarget, type DigestSectionKind, type DigestTimestampMode, } from "../lib/digest"; import { PROMPT_VERSION, maxCuesForContext } from "../lib/digestPrompt"; import { readDigestContext, type DigestContext, } from "../lib/digestContext-server"; // Which lane to resolve against. The string form the editor actions already // speak, rather than DigestLane, so call sites pass what they already hold. export type DigestLaneChoice = "local" | "remote"; export type ResolvedDigestTarget = { target: DigestFreshnessTarget; // Which sections must ALL be fresh for a video to count as digested. sections: DigestSectionKind[]; app: DigestApp; config: DigestAppConfig; // What the config asked for. Distinct from what the engine reports having run // — freshness compares this one (see DigestProvenance.modelRequested). modelRequested: string; timestampMode: DigestTimestampMode; // Cues per chunk at this app's context size. The batch passes it on to the // chunker, so the identity and the actual chunking cannot drift apart. maxCues: number; // The channel-context note + its hash. Returned rather than re-read because // the hash is already IN the target above: reading it twice invites the two // copies to disagree. context: DigestContext; }; // Which app a lane resolves to, and the chunk size that follows from its context. // // Split out of resolveDigestTarget because the PRICING pass needs the chunk size // without needing a channel: the number of chunks a transcript costs depends only // on `maxCues`, while a freshness target additionally needs the per-channel // context note. Sharing this keeps the plan's chunk count and the chunker's // actual slicing derived from one place — the alternative is a second copy of // `maxCuesForContext(config.numCtx)` that silently stops matching the engine. export function resolveDigestChunking(opts: { lane?: DigestLaneChoice; appId?: string; }): { app: DigestApp; config: DigestAppConfig; maxCues: number } { const digestSettings = getSettings().digest; const appId = opts.appId ?? (opts.lane === "remote" ? digestSettings.remoteAppId : digestSettings.localAppId); const app = getDigestApp(appId); const config: DigestAppConfig = digestSettings.apps[app.id] ?? {}; return { app, config, maxCues: maxCuesForContext(config.numCtx) }; } export async function resolveDigestTarget(opts: { paths: Paths; channelSlug: string; lane?: DigestLaneChoice; // Overrides the lane's configured app. The bake-off harness drives this. appId?: string; sections?: DigestSectionKind[]; }): Promise { const digestSettings = getSettings().digest; const { app, config, maxCues } = resolveDigestChunking(opts); const modelRequested = config.model?.trim() || app.defaultModel(); const promptVariant = digestPromptVariant({ ...digestSettings, maxCues, }); const context = await readDigestContext(opts.paths, opts.channelSlug); return { target: { appId: app.id, model: modelRequested, promptVersion: PROMPT_VERSION, contextHash: context.hash, ...(promptVariant ? { promptVariant } : {}), }, sections: opts.sections ?? digestSettings.sections, app, config, modelRequested, timestampMode: digestSettings.timestampMode, maxCues, context, }; }