commit 96b068b283ac6a8c744ff0374e4420a231a792bd
parent 52ed923ea1d8431e09f4d790633537cd73d8a84f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 27 Jul 2026 00:46:46 -0400
Per-video digest review panel — make the layer inspectable
readVideoDigestAction, saveDigestOverridesAction and digestBucketAction all
shipped with Stage B1 and had NO caller: the digest layer produced
artifacts that nothing in the app could display. A ~100-video validation
run cannot mean anything while its output is invisible.
The panel leads with what might be WRONG, not with what looks right:
- warnings[] first, grouped by guard code with the first offending value
verbatim. This is the array Phase 11a's review queue will read; a section
generated from 3 of 5 chunks looks perfectly healthy until you read them.
- Per-section provenance: engine, requested-vs-actual model, lane, prompt
version, prompt variant, context hash, chunks ok/total, cost. During a
bake-off-driven validation this IS the point — two videos can carry
similar-looking chapters from entirely different configurations.
- A freshness badge per section, computed against the same
resolveDigestTarget the batch runner uses, and a stale section states
what a regeneration would replace it with.
- derivedFrom rendered explicitly: which canonical member it was shared
from and the measured cue offset that made placing it safe. A borrowed
digest must never read as native.
- Empty state, because that is what an operator sees for most of a sweep.
Actions: regenerate this one video on either lane (StreamActionLog, so the
live log and cancel come free), and hand corrections — retitle a chapter or
untick it to suppress without deleting, id-keyed so a regeneration that
re-emits the same segmentation keeps honoring them. Writes only ever touch
ai-digest.overrides.json.
Also adds "digest" to StatusHeader's BADGE_ORDER, which omitted it even
though the stage rail already included it.
e2e: renders provenance + warnings + chapters, flips to stale after a real
identity change, and corrections land in the overrides sidecar with the
machine file byte-unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat:
5 files changed, 682 insertions(+), 0 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **You can now read what the digest layer produced, and correct it, from the video page.** The digest generator shipped with nowhere for a human to look at its output — the channel Digest stage is a queue-and-count card, and the per-video page had no digest reference at all. Each video page now carries an **AI digest** panel that leads with what might be *wrong*: the `warnings` recorded during generation (grouped by guard, with the first offending value verbatim), then the **provenance** of each section — engine, requested vs actual model, lane, prompt version, prompt variant, context hash, and how many chunks of how many came back usable — then the chapters and tags themselves. A **freshness badge** says whether the digest still matches what a regeneration would produce right now, and a stale section spells out what it *would* be replaced with; changing an engine, model, prompt version or prompt shape is exactly what makes it stale, so this is where you see that a config change has invalidated your corpus. A digest that was **shared from a duplicate cluster's canonical member** says so plainly, links to the video it came from, and shows the measured cue offset that made placing it here safe — a borrowed digest is never presented as native. From the panel you can regenerate this one video on either lane (live log, cancellable, the same job machinery as a channel sweep) and hand-correct chapters: retitle one, or untick it to suppress it without deleting it, so a regeneration that re-emits the same item cannot resurrect something you rejected. Corrections are written **only** to `ai-digest.overrides.json` and never to the generated file. Settings also gains the **Digest** section the rest of the app has been pointing at (local engine, sections to generate, timestamp mode, prompt-variant label, per-engine model / context window / temperature, and the metered lane with its spend cap and long-tail cutoff), and **Digest** now appears in the channel status-header badges. See `editor/app/channels/[slug]/videos/[id]/components/DigestPanel.tsx`, `editor/app/settings/components/{SettingsForm,DigestAppsField}.tsx`, and `editor/e2e/digest.spec.ts`.
- **Duplicate detection now runs over the whole archive, not just shorts.** It previously ran out of memory on a full-corpus pass and was left off. Two things were actually wrong, and both are fixed: candidate pairs were generated by pairing every short with every longer video (half a billion pairs), and transcript fingerprints for the entire corpus were held in memory at once. Detection now works one block of similar videos at a time — fingerprint, compare, discard — so a corpus-wide run finishes in minutes at ordinary memory. A new **`--blocking`** flag on `duplicate-shorts` chooses how candidates are proposed: `title` (default for a full-archive run — fast, finds cross-platform re-uploads that kept their name), `duration` (slower, but the only one that finds a *re-titled* mirror), or `both`. The choice only affects which pairs get *considered*; what counts as a duplicate is still decided by comparing the actual transcripts.
- **Videos that share a title and a runtime are now surfaced for review instead of being dropped.** When one side has no transcript there is nothing to compare, so detection can't rule either way. Rather than discarding the pair, it is reported as a cluster badged **needs review** — visible in the archive's own tooling, excluded from every built site, and blocked from sharing AI digests until you confirm it. Clusters whose transcripts *were* compared are unaffected and behave exactly as before. Note that this test is doing real work: on the full archive, **63% of same-title, same-length pairs turned out not to be the same video.**
- **Detection now records how well each copy's timings line up with the original.** Every confirmed cluster stores, per member, whether its transcript timings align with the cluster's canonical video and by how much. This is what lets the viewer offer "jump to this moment in the other copy" only when that moment actually corresponds — and lets a shared AI digest be placed correctly rather than plausibly.
diff --git a/editor/app/channels/[slug]/components/StatusHeader.tsx b/editor/app/channels/[slug]/components/StatusHeader.tsx
@@ -8,6 +8,7 @@ const BADGE_ORDER: StageId[] = [
"download",
"transcode",
"transcribe",
+ "digest",
"cleanup",
"diagnostics",
];
diff --git a/editor/app/channels/[slug]/videos/[id]/components/DigestPanel.tsx b/editor/app/channels/[slug]/videos/[id]/components/DigestPanel.tsx
@@ -0,0 +1,514 @@
+"use client";
+
+// The per-video digest review surface — the thing that makes a validation run
+// readable.
+//
+// Until this existed the digest layer had nowhere for a human to LOOK at what it
+// produced: the channel Digest stage is a queue-and-count card by its own
+// admission, and the per-video page had no digest reference at all. A ~100-video
+// validation run cannot mean anything while its output is invisible, and
+// STATE.md's own lesson from this layer is that "the digest layer's failure
+// modes live at the wiring, not in the pure functions."
+//
+// So this panel leads with the things that are wrong rather than the things that
+// are right: warnings first, then provenance (WHICH configuration produced
+// this), then the items. During a bake-off-driven validation the provenance line
+// is the entire point — two videos can carry visually similar chapters from
+// completely different engines, models or prompt versions.
+
+import { useState, useTransition } from "react";
+import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
+import {
+ digestBucketAction,
+ saveDigestOverridesAction,
+ type DigestLaneChoice,
+} from "../../../digestActions";
+import type {
+ DigestChapter,
+ DigestProvenance,
+ DigestSectionKind,
+ DigestWarning,
+ EffectiveDigest,
+} from "yt-dlp-transcript-common/lib/digest";
+import { cancelJobAction } from "../../../../../jobs/actions";
+
+// One row per section KIND the current settings ask for, so a section that was
+// never generated is as visible as one that is stale. Computed on the server —
+// freshness needs the resolved target, which reaches settings and disk.
+export type DigestSectionState = {
+ kind: DigestSectionKind;
+ present: boolean;
+ fresh: boolean;
+ provenance?: DigestProvenance;
+};
+
+export type DigestPanelData = {
+ digest: EffectiveDigest;
+ hasMachineDigest: boolean;
+ sections: DigestSectionState[];
+ // What a regeneration would produce right now, for the "stale because…" line.
+ target: {
+ appId: string;
+ model: string;
+ promptVersion: number;
+ promptVariant?: string;
+ contextHash: string;
+ };
+ hasOverrides: boolean;
+ note: string;
+ remoteEnabled: boolean;
+};
+
+export function DigestPanel({
+ slug,
+ videoId,
+ data,
+}: {
+ slug: string;
+ videoId: string;
+ data: DigestPanelData;
+}) {
+ const [lane, setLane] = useState<DigestLaneChoice>("local");
+ const { digest, sections, target } = data;
+ const anyStale = sections.some((s) => !s.fresh);
+
+ return (
+ <section
+ aria-label="digest panel"
+ className="flex flex-col gap-4 rounded border border-border p-3"
+ >
+ <div className="flex flex-wrap items-baseline justify-between gap-2">
+ <h2 className="text-lg font-semibold">AI digest</h2>
+ <span
+ aria-label="digest freshness"
+ className={
+ !data.hasMachineDigest
+ ? "text-xs text-muted-foreground"
+ : anyStale
+ ? "text-xs font-medium text-warning"
+ : "text-xs font-medium text-success"
+ }
+ >
+ {!data.hasMachineDigest
+ ? "not generated"
+ : anyStale
+ ? "stale — regenerating would replace this"
+ : "current"}
+ </span>
+ </div>
+
+ {data.digest.derivedFrom && (
+ <SharedFrom derivedFrom={data.digest.derivedFrom} />
+ )}
+
+ {!data.hasMachineDigest ? (
+ <p aria-label="digest empty" className="text-sm text-muted-foreground">
+ No digest has been generated for this video yet. Run one below, or
+ sweep the whole channel from the channel page's Digest stage.
+ </p>
+ ) : (
+ <>
+ <Warnings warnings={digest.warnings} />
+ <SectionProvenance sections={sections} target={target} />
+ <Chapters chapters={digest.chapters} />
+ <Tags tags={digest.tags} />
+ </>
+ )}
+
+ <OverridesEditor
+ slug={slug}
+ videoId={videoId}
+ chapters={digest.chapters}
+ note={data.note}
+ hasOverrides={data.hasOverrides}
+ />
+
+ <div className="flex flex-col gap-2 border-t border-border pt-3">
+ <StreamActionLog
+ trigger={() => digestBucketAction(slug, [videoId], lane)}
+ cancelAction={cancelJobAction}
+ buttonLabel="Regenerate digest"
+ runningLabel="Generating…"
+ label="Regenerate digest"
+ extraControls={
+ <label className="flex items-center gap-1 text-xs text-muted-foreground">
+ Lane
+ <select
+ value={lane}
+ onChange={(e) => setLane(e.target.value as DigestLaneChoice)}
+ aria-label="lane for Regenerate digest"
+ className="rounded border border-border bg-card px-1 py-0.5"
+ >
+ <option value="local">local (ollama)</option>
+ <option value="remote" disabled={!data.remoteEnabled}>
+ metered{data.remoteEnabled ? "" : " — disabled in Settings"}
+ </option>
+ </select>
+ </label>
+ }
+ />
+ <p className="text-xs text-muted-foreground">
+ Regeneration only ever rewrites <code>ai-digest.json</code>. Hand
+ corrections live in <code>ai-digest.overrides.json</code> and are never
+ touched by it — which is what lets a correction survive a re-sweep of a
+ corpus too large to generate twice.
+ </p>
+ </div>
+ </section>
+ );
+}
+
+// A borrowed digest must NEVER be presented as native. Sharing only happens at
+// near-zero measured offset, so the offset is shown as the reason it was
+// considered safe rather than left implicit.
+function SharedFrom({
+ derivedFrom,
+}: {
+ derivedFrom: NonNullable<EffectiveDigest["derivedFrom"]>;
+}) {
+ const [channelSlug, id] = derivedFrom.slug.split("/");
+ return (
+ <div
+ aria-label="digest shared from"
+ className="rounded border border-info/30 bg-info-soft p-2 text-sm"
+ >
+ <p>
+ <strong>Shared from a duplicate.</strong> This digest was generated for{" "}
+ <a
+ href={`/channels/${channelSlug}/videos/${id}`}
+ className="underline font-mono"
+ >
+ {derivedFrom.slug}
+ </a>
+ , the canonical member of its cluster, and copied here.
+ </p>
+ <p className="text-xs text-muted-foreground mt-1">
+ Cue timings were measured{" "}
+ <strong>{derivedFrom.offsetSeconds.toFixed(2)}s</strong> apart at the
+ sampled anchors, which is why placing it here was considered safe.
+ Regenerating the canonical member re-applies the share.
+ </p>
+ </div>
+ );
+}
+
+// Warnings lead. This is the array Phase 11a's review queue is built on, and
+// showing it per-video first is what makes a validation run readable: a section
+// generated from 3 of 5 chunks looks perfectly healthy until you read these.
+function Warnings({ warnings }: { warnings: DigestWarning[] }) {
+ if (warnings.length === 0) {
+ return (
+ <p aria-label="digest warnings empty" className="text-xs text-success">
+ No warnings — every chunk parsed and every item passed the guards.
+ </p>
+ );
+ }
+ const byCode = new Map<string, DigestWarning[]>();
+ for (const w of warnings) {
+ const list = byCode.get(w.code) ?? [];
+ list.push(w);
+ byCode.set(w.code, list);
+ }
+ return (
+ <div
+ aria-label="digest warnings"
+ className="rounded border border-warning/30 bg-warning-soft p-2"
+ >
+ <p className="text-sm font-medium">
+ {warnings.length} warning{warnings.length === 1 ? "" : "s"}
+ </p>
+ <ul className="mt-1 flex flex-col gap-1 text-xs">
+ {Array.from(byCode.entries()).map(([code, list]) => (
+ <li key={code} aria-label={`digest warning ${code}`}>
+ <code className="font-medium">{code}</code> × {list.length}
+ <span className="text-muted-foreground">
+ {" "}
+ ({list[0].section}
+ {list[0].chunk !== undefined ? `, chunk ${list[0].chunk}` : ""})
+ </span>
+ {list[0].value && (
+ <span className="text-muted-foreground">
+ {" "}
+ — first: <code>{list[0].value}</code>
+ </span>
+ )}
+ {list[0].detail && (
+ <span className="text-muted-foreground"> — {list[0].detail}</span>
+ )}
+ </li>
+ ))}
+ </ul>
+ </div>
+ );
+}
+
+// WHICH configuration produced this. During a bake-off-driven validation, two
+// videos can carry similar-looking chapters from entirely different engines.
+function SectionProvenance({
+ sections,
+ target,
+}: {
+ sections: DigestSectionState[];
+ target: DigestPanelData["target"];
+}) {
+ return (
+ <div aria-label="digest provenance" className="flex flex-col gap-2">
+ {sections.map((s) => (
+ <div
+ key={s.kind}
+ aria-label={`digest section ${s.kind}`}
+ className="rounded border border-border p-2 text-xs"
+ >
+ <div className="flex flex-wrap items-baseline gap-2">
+ <span className="text-sm font-medium">{s.kind}</span>
+ {!s.present ? (
+ <span className="text-muted-foreground">never generated</span>
+ ) : s.fresh ? (
+ <span className="text-success">current</span>
+ ) : (
+ <span className="text-warning">stale</span>
+ )}
+ </div>
+ {s.provenance ? (
+ <dl className="mt-1 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 font-mono">
+ <Row label="engine" value={s.provenance.appId} />
+ <Row
+ label="model"
+ value={
+ s.provenance.modelRequested &&
+ s.provenance.modelRequested !== s.provenance.model
+ ? `${s.provenance.modelRequested} → ${s.provenance.model}`
+ : s.provenance.model
+ }
+ />
+ <Row label="lane" value={s.provenance.lane} />
+ <Row
+ label="promptVersion"
+ value={String(s.provenance.promptVersion)}
+ />
+ <Row
+ label="promptVariant"
+ value={s.provenance.promptVariant ?? "(default)"}
+ />
+ <Row label="contextHash" value={s.provenance.contextHash} />
+ {s.provenance.chunks !== undefined && (
+ <Row
+ label="chunks"
+ value={`${s.provenance.chunksOk ?? "?"} of ${s.provenance.chunks} usable`}
+ />
+ )}
+ <Row label="generated" value={s.provenance.generatedAt} />
+ {s.provenance.costUsd !== undefined && (
+ <Row label="cost" value={`$${s.provenance.costUsd.toFixed(4)}`} />
+ )}
+ </dl>
+ ) : null}
+ {s.present && !s.fresh && (
+ <p className="mt-1 text-muted-foreground">
+ A regeneration would produce{" "}
+ <code>
+ {target.appId}/{target.model} v{target.promptVersion}
+ {target.promptVariant ? ` (${target.promptVariant})` : ""}
+ </code>
+ .
+ </p>
+ )}
+ </div>
+ ))}
+ </div>
+ );
+}
+
+function Row({ label, value }: { label: string; value: string }) {
+ return (
+ <>
+ <dt className="text-muted-foreground">{label}</dt>
+ <dd className="break-all">{value}</dd>
+ </>
+ );
+}
+
+function Chapters({ chapters }: { chapters: DigestChapter[] }) {
+ if (chapters.length === 0) {
+ return (
+ <p aria-label="digest chapters empty" className="text-sm text-muted-foreground">
+ No chapters.
+ </p>
+ );
+ }
+ return (
+ <div>
+ <h3 className="text-sm font-medium">Chapters ({chapters.length})</h3>
+ <ol aria-label="digest chapters" className="mt-1 flex flex-col gap-0.5">
+ {chapters.map((c) => (
+ <li
+ key={c.id}
+ aria-label={`digest chapter ${c.id}`}
+ className="flex gap-2 text-sm"
+ >
+ <span className="font-mono text-muted-foreground">{c.clock}</span>
+ <span>{c.title}</span>
+ {c.decidedBy === "human" && (
+ <span className="text-xs text-info">edited</span>
+ )}
+ </li>
+ ))}
+ </ol>
+ </div>
+ );
+}
+
+function Tags({ tags }: { tags: EffectiveDigest["tags"] }) {
+ if (tags.length === 0) return null;
+ return (
+ <div>
+ <h3 className="text-sm font-medium">Tags ({tags.length})</h3>
+ <ul aria-label="digest tags" className="mt-1 flex flex-wrap gap-1">
+ {tags.map((t) => (
+ <li
+ key={t.id}
+ className="rounded bg-muted px-1.5 py-0.5 text-xs"
+ >
+ {t.tag}
+ {t.decidedBy === "human" && <span className="text-info"> ✎</span>}
+ </li>
+ ))}
+ </ul>
+ </div>
+ );
+}
+
+// Corrections. Deliberately minimal: retitle a chapter, or suppress it. Those
+// are the two things a reviewer actually does, and both are id-keyed so a
+// regeneration that re-emits the same segmentation keeps honoring them.
+function OverridesEditor({
+ slug,
+ videoId,
+ chapters,
+ note,
+ hasOverrides,
+}: {
+ slug: string;
+ videoId: string;
+ chapters: DigestChapter[];
+ note: string;
+ hasOverrides: boolean;
+}) {
+ const [rows, setRows] = useState(() =>
+ chapters.map((c) => ({ id: c.id, title: c.title, enabled: true })),
+ );
+ const [noteText, setNoteText] = useState(note);
+ const [pending, startTransition] = useTransition();
+ const [result, setResult] = useState<string | null>(null);
+
+ // The panel re-renders from the server after a save (revalidatePath), so the
+ // row list can go stale against a regeneration that happened in between.
+ const chapterIds = chapters.map((c) => c.id).join(",");
+ const rowIds = rows.map((r) => r.id).join(",");
+ if (chapterIds !== rowIds) {
+ setRows(chapters.map((c) => ({ id: c.id, title: c.title, enabled: true })));
+ }
+
+ if (chapters.length === 0 && !hasOverrides) return null;
+
+ const save = (): void => {
+ setResult(null);
+ startTransition(async () => {
+ const edited = rows.filter((row, i) => {
+ const original = chapters[i];
+ return (
+ !original || row.title !== original.title || row.enabled === false
+ );
+ });
+ const res = await saveDigestOverridesAction(slug, videoId, {
+ chapters: edited.map((row) => ({
+ id: row.id,
+ // start/clock are patched in from the machine item by mergeItems — an
+ // override authored as (id + title) must not blank them.
+ start: chapters.find((c) => c.id === row.id)?.start ?? 0,
+ clock: chapters.find((c) => c.id === row.id)?.clock ?? "00:00:00",
+ title: row.title,
+ decidedBy: "human" as const,
+ ...(row.enabled ? {} : { enabled: false }),
+ })),
+ ...(noteText.trim() ? { note: noteText.trim() } : {}),
+ });
+ setResult(res.ok ? "Saved." : res.error);
+ });
+ };
+
+ return (
+ <details className="text-sm">
+ <summary className="cursor-pointer font-medium">
+ Correct this digest{hasOverrides ? " (edited)" : ""}
+ </summary>
+ <div className="mt-2 flex flex-col gap-2">
+ {rows.map((row, i) => (
+ <div key={row.id} className="flex items-center gap-2">
+ <input
+ type="checkbox"
+ checked={row.enabled}
+ aria-label={`keep chapter ${row.id}`}
+ onChange={(e) =>
+ setRows((prev) =>
+ prev.map((r, j) =>
+ j === i ? { ...r, enabled: e.target.checked } : r,
+ ),
+ )
+ }
+ />
+ <span className="font-mono text-xs text-muted-foreground">
+ {chapters[i]?.clock}
+ </span>
+ <input
+ type="text"
+ value={row.title}
+ aria-label={`title for chapter ${row.id}`}
+ onChange={(e) =>
+ setRows((prev) =>
+ prev.map((r, j) =>
+ j === i ? { ...r, title: e.target.value } : r,
+ ),
+ )
+ }
+ className="flex-1 rounded border border-border bg-card px-2 py-1 text-sm"
+ />
+ </div>
+ ))}
+ <label className="flex flex-col gap-1">
+ <span className="text-xs font-medium">Note (why)</span>
+ <input
+ type="text"
+ value={noteText}
+ aria-label="digest override note"
+ onChange={(e) => setNoteText(e.target.value)}
+ className="rounded border border-border bg-card px-2 py-1 text-sm"
+ />
+ </label>
+ <div className="flex items-center gap-3">
+ <button
+ type="button"
+ onClick={save}
+ disabled={pending}
+ className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50"
+ >
+ {pending ? "Saving…" : "Save corrections"}
+ </button>
+ {result && (
+ <span
+ role="status"
+ className="text-sm text-muted-foreground"
+ >
+ {result}
+ </span>
+ )}
+ </div>
+ <p className="text-xs text-muted-foreground">
+ Unchecking a chapter suppresses it without deleting it, so a
+ regeneration that re-emits the same item does not resurrect something
+ you rejected.
+ </p>
+ </div>
+ </details>
+ );
+}
diff --git a/editor/app/channels/[slug]/videos/[id]/page.tsx b/editor/app/channels/[slug]/videos/[id]/page.tsx
@@ -26,8 +26,23 @@ import {
queueKeyForUrl,
} from "yt-dlp-transcript-common/lib/platform";
import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import {
+ loadDigest,
+ loadDigestOverrides,
+} from "yt-dlp-transcript-common/lib/digest-server";
+import {
+ effectiveDigest,
+ isSectionFresh,
+} from "yt-dlp-transcript-common/lib/digest";
+import { resolveDigestTarget } from "yt-dlp-transcript-common/controller/digestTarget";
import { RunningJobsList } from "../../../../jobs/components/RunningJobsList";
import { VideoPanel, type VideoFile } from "./components/VideoPanel";
+import {
+ DigestPanel,
+ type DigestPanelData,
+ type DigestSectionState,
+} from "./components/DigestPanel";
export const dynamic = "force-dynamic";
@@ -137,6 +152,37 @@ export default async function VideoDetailPage({
}
: null;
+ // The digest artifact, its human shadow, and whether each section still
+ // matches what a regeneration would produce. Freshness is computed HERE
+ // rather than in the panel because it needs the resolved target, which reads
+ // settings, the engine registry and the channel's context note.
+ const [digestRecord, digestOverrides, digestTarget] = await Promise.all([
+ loadDigest(videoDir),
+ loadDigestOverrides(videoDir),
+ resolveDigestTarget({ paths: getPaths(), channelSlug: slug, lane: "local" }),
+ ]);
+ const digestData: DigestPanelData = {
+ digest: effectiveDigest(digestRecord, digestOverrides),
+ hasMachineDigest: digestRecord !== null,
+ sections: digestTarget.sections.map(
+ (kind): DigestSectionState => ({
+ kind,
+ present: digestRecord?.sections[kind] !== undefined,
+ // A shared digest is fresh for the receiving video as long as it still
+ // points at its canonical member — that member's own freshness is what
+ // drives regeneration (isSharedFrom's contract).
+ fresh:
+ digestRecord?.derivedFrom != null ||
+ isSectionFresh(digestRecord, kind, digestTarget.target),
+ provenance: digestRecord?.sections[kind]?.provenance,
+ }),
+ ),
+ target: digestTarget.target,
+ hasOverrides: digestOverrides !== null,
+ note: digestOverrides?.note ?? "",
+ remoteEnabled: getSettings().digest.remoteEnabled,
+ };
+
const registry = getRegistry();
const existingQueues = registry.activeQueueNames();
const activeJobs = registry
@@ -219,6 +265,8 @@ export default async function VideoDetailPage({
savedVideo={savedVideo}
coverage={coverage}
/>
+
+ <DigestPanel slug={slug} videoId={id} data={digestData} />
</div>
);
}
diff --git a/editor/e2e/digest.spec.ts b/editor/e2e/digest.spec.ts
@@ -548,3 +548,121 @@ test.afterAll(async () => {
force: true,
});
});
+
+// ---------------------------------------------------------------------------
+// 10. The per-video review panel.
+//
+// The backend for this (readVideoDigestAction, saveDigestOverridesAction,
+// digestBucketAction) shipped with Stage B1 and had NO caller — the digest layer
+// produced artifacts nothing could display. A validation run is unreadable while
+// its output is invisible, so these cover the three things the panel exists to
+// show: what was produced, WHICH configuration produced it, and whether that is
+// still the current one.
+// ---------------------------------------------------------------------------
+
+test("the video panel renders a digest with its provenance and warnings", async ({
+ page,
+}) => {
+ await resetData(null);
+ await writeSettings(digestSettings());
+ await writeChannelConfig(CHANNEL);
+ await writeDigestVideo({ channelSlug: CHANNEL, videoId: VIDEO });
+
+ // Empty state first — it is what an operator sees for most of a sweep.
+ await page.goto(`/channels/${CHANNEL}/videos/${VIDEO}`);
+ await expect(page.getByLabel("digest empty")).toBeVisible();
+ await expect(page.getByLabel("digest freshness")).toHaveText("not generated");
+
+ await runDigest(page, CHANNEL);
+
+ await page.goto(`/channels/${CHANNEL}/videos/${VIDEO}`);
+ await expect(page.getByLabel("digest freshness")).toHaveText("current");
+
+ // Chapters, with the clock the model actually emitted.
+ const chapters = page.getByLabel("digest chapters").locator("li");
+ expect(await chapters.count()).toBeGreaterThan(0);
+
+ // Provenance — the whole point during a bake-off-driven validation.
+ const section = page.getByLabel("digest section chapters");
+ await expect(section).toContainText("ollama-direct");
+ await expect(section).toContainText("promptVersion");
+ await expect(section).toContainText("contextHash");
+ await expect(section).toContainText("current");
+
+ // Warnings are always accounted for — either a list or an explicit "none".
+ // `exact` matters: getByLabel is substring-matched by default, so the plain
+ // form would also match "digest warnings empty" and count both states.
+ const hasWarnings = await page
+ .getByLabel("digest warnings", { exact: true })
+ .count();
+ const noWarnings = await page
+ .getByLabel("digest warnings empty", { exact: true })
+ .count();
+ expect(hasWarnings + noWarnings).toBe(1);
+});
+
+test("the video panel shows the digest as stale after a config change", async ({
+ page,
+}) => {
+ await resetData(null);
+ await writeSettings(digestSettings());
+ await writeChannelConfig(CHANNEL);
+ await writeDigestVideo({ channelSlug: CHANNEL, videoId: VIDEO });
+ await runDigest(page, CHANNEL);
+
+ await page.goto(`/channels/${CHANNEL}/videos/${VIDEO}`);
+ await expect(page.getByLabel("digest freshness")).toHaveText("current");
+
+ // A real identity change — the same path a prompt or model change takes.
+ await writeSettings(
+ digestSettings({ apps: { "ollama-direct": { model: "qwen2.5:7b-v2" } } }),
+ );
+
+ await page.goto(`/channels/${CHANNEL}/videos/${VIDEO}`);
+ await expect(page.getByLabel("digest freshness")).toContainText("stale");
+ await expect(page.getByLabel("digest section chapters")).toContainText(
+ "qwen2.5:7b-v2",
+ );
+});
+
+test("corrections saved from the panel land in the overrides sidecar only", async ({
+ page,
+}) => {
+ await resetData(null);
+ await writeSettings(digestSettings());
+ await writeChannelConfig(CHANNEL);
+ await writeDigestVideo({ channelSlug: CHANNEL, videoId: VIDEO });
+ await runDigest(page, CHANNEL);
+
+ const before = await readJson<DigestRecord>(digestPath(CHANNEL, VIDEO));
+ const firstId = (before.sections.chapters?.items ?? [])[0]?.id;
+ expect(firstId).toBeTruthy();
+
+ await page.goto(`/channels/${CHANNEL}/videos/${VIDEO}`);
+ await page.getByText("Correct this digest").click();
+ await page
+ .getByLabel(`title for chapter ${firstId}`)
+ .fill("Renamed from the panel");
+ await page.getByLabel("digest override note").fill("panel e2e");
+ await page.getByRole("button", { name: "Save corrections" }).click();
+ await expect(page.getByRole("status").filter({ hasText: "Saved" })).toBeVisible();
+
+ const overrides = await readJson<DigestOverrides>(
+ overridesPath(CHANNEL, VIDEO),
+ );
+ expect(overrides.chapters?.[0].title).toBe("Renamed from the panel");
+ expect(overrides.chapters?.[0].decidedBy).toBe("human");
+ expect(overrides.note).toBe("panel e2e");
+
+ // The machine file is untouched — the invariant the two sidecars exist for.
+ const after = await readJson<DigestRecord>(digestPath(CHANNEL, VIDEO));
+ expect(after.sections.chapters?.items[0].title).toBe(
+ before.sections.chapters?.items[0].title,
+ );
+
+ // And the composed view reports the edit as human.
+ await page.goto(`/channels/${CHANNEL}/videos/${VIDEO}`);
+ await expect(page.getByLabel("digest chapters")).toContainText(
+ "Renamed from the panel",
+ );
+});