feat(moderation): moderate transcript text for video assets#208
feat(moderation): moderate transcript text for video assets#208claude[bot] wants to merge 6 commits into
Conversation
Add an opt-in includeTranscript option to getModerationScores so that video assets can have their caption transcript moderated alongside storyboard thumbnails. Transcript moderation runs only with provider 'openai', is skipped silently when no ready text track exists (never triggering transcription), and folds transcript scores into thumbnailScores with a synthetic transcript: URL prefix. Thumbnail coverage excludes those transcript entries from its denominator.
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
…ores field BREAKING CHANGE: transcript moderation results no longer appear in `thumbnailScores`. They now live in a dedicated `transcriptScores` array (`TranscriptModerationScore[]`), keyed by `chunkIndex` instead of a synthetic `transcript:` URL. `thumbnailScores` holds image entries only. Adds a `combined` value to `mode` for video assets moderated with `includeTranscript`. maxScores/exceedsThreshold aggregate across both arrays; thumbnail coverage is naturally image-only and audio-only results stay confident with zero thumbnails.
…codes Replace the arbitrary ~10k-char chunk model with time windows aligned to caption cues. Each transcript score now carries startTime/endTime (like a thumbnail's time) instead of a chunkIndex, so consumers can locate flagged speech on the timeline. Consecutive cues are grouped into contiguous, non-overlapping windows that stay under the OpenAI moderation input budget; a single over-budget cue is emitted as its own window covering its full time range. maxScores/exceedsThreshold and the all-failed guard aggregate across thumbnails plus all transcript windows.
… moderation Replace the fixed char-budget transcript windowing with dynamic, overlapping time windows whose size scales with the asset's duration (clamp(duration / 40, 20s, 120s)) and overlap (~15%, min 5s) so abuse straddling a window boundary is still scored intact. Windows are sent to OpenAI as array-batched /v1/moderations requests, with results mapped back index-aligned; an oversized batch (400) is split in half and retried down to a single window, reusing existing 429/5xx retry/backoff and per-batch concurrency. Add a tunable transcriptWindowing option and export a pure buildTranscriptWindows helper for direct unit testing.
I'm not sure how I feel about this part. Maybe this is already accounted for within the code and I haven't seen it yet. If I pass the Boolean flag to enable include transcript, but it silently gets skipped because the text track isn't ready, that would be a broken implementation detail in my mind. Now, granted, sometimes the transcript doesn't net any audible cues worth moderating or even useful for captions. I'd still prefer, in those scenarios, to explicitly be able to track that moderation was skipped because of that. I think it helps with audit trails. |
Add explicit `transcriptModeration: TranscriptModerationStatus` to
`ModerationResult` so callers can audit whether transcript moderation
was requested, completed, or skipped — and, when skipped, a
machine-readable `skipReason` (`no_ready_text_track` /
`no_transcript_content` / `no_cues`) plus a human-readable
`skipMessage`. Video assets that don't opt in report
`{ requested: false, status: "not_requested" }`; audio-only and video +
`includeTranscript` runs that produce cues report
`{ requested: true, status: "completed" }`. Non-openai provider guard
still throws (caller error, not a skip).
|
Done in |
Requested by Victor Boutté, Phil Cluff · Slack thread
Summary
Video assets previously only had their storyboard thumbnails moderated — the caption transcript was never checked. This PR adds an opt-in
includeTranscriptoption so a video asset can also moderate its caption transcript via OpenAI text moderation, returns transcript results in a dedicatedtranscriptScoresarray, moves the transcript into dynamic, overlapping time windows whose size scales with the asset's duration, and surfaces atranscriptModerationstatus object so skipped moderations are auditable, not silent.Before
For video assets,
getModerationScoresonly moderated storyboard thumbnails — the caption transcript was never checked. (Audio-only assets already moderate transcript text; the two paths were mutually exclusive.) Audio-only transcript results were returned inthumbnailScoresusing a synthetictranscript:-prefixedurl.After
A new opt-in
includeTranscript?: booleanoption (defaultfalse) makes a video asset also moderate its caption transcript text alongside thumbnails, and transcript results now live in a dedicatedtranscriptScoresarray instead of being folded intothumbnailScores. Each transcript score is segmented into a time window carryingstartTime/endTimetimecodes — analogous to how a flagged thumbnail carries itstime— so consumers can locate flagged speech on the timeline:provider: 'openai'(uses OpenAI text moderation). Throws if set withhive/google-vision-api.includeTranscript: trueon a video) but nothing was moderated — because there is no ready caption track, the track was empty, or the VTT had no parseable cues — the result reports it via the newtranscriptModeration: TranscriptModerationStatusfield with an explicitstatus: "skipped", a machine-readableskipReason("no_ready_text_track"/"no_transcript_content"/"no_cues"), and a human-readableskipMessage. Transcription is never triggered.transcriptModeration: TranscriptModerationStatusto the result so callers can audit whether transcript moderation was requested, completed, or skipped (and why). Video assets that don't opt in report{ requested: false, status: "not_requested" }; audio-only and video +includeTranscriptruns that produce cues report{ requested: true, status: "completed" }. No more silent skips.sexual+violenceonly, thresholds0.8/0.8(unchangedDEFAULT_THRESHOLDS).docs/API.mdgains a "Completeness and coverage" subsection that level-sets what was scored and how completely — every scored unit is returned (scoring never stops at the first flag), thumbnail (visual) coverage is sampled/not exhaustive, and transcript (text) coverage is exhaustive with overlapping windows to dedupe.How
src/workflows/moderation.tsTranscriptModerationScoreis time-windowed:{ startTime, endTime, sexual, violence, error, errorMessage? }(timecodes in seconds; error fields mirrorThumbnailModerationScore). There is nochunkIndex.ModerationResult.transcriptScores: TranscriptModerationScore[]is one entry per time window (empty[]when nothing was moderated).thumbnailScoresholds image entries only (always a realurl+time).New
TranscriptModerationStatusonModerationResult.transcriptModeration:{ requested, status, skipReason?, skipMessage? }withstatus: 'completed' | 'skipped' | 'not_requested'andskipReason: 'no_ready_text_track' | 'no_transcript_content' | 'no_cues'. Populated at each branch insidegetModerationScores— the caller can now tell "no caption track" apart from "moderated cleanly with no findings" without inspecting an emptytranscriptScores.Dynamic, overlapping windowing (replaces the fixed char-budget chunking): transcript fetch pulls the raw VTT (
cleanTranscript: false) and parses per-cue timecodes viaparseVTTCues. A new exported pure helperbuildTranscriptWindows(cues, duration, params)builds overlapping time windows whose size scales with the asset's duration:windowSeconds = clamp(duration / targetWindowCount, minWindowSeconds, maxWindowSeconds)(defaultsclamp(duration / 40, 20, 120))overlapSeconds = max(minOverlapSeconds, windowSeconds * overlapFraction)(defaultsmax(5, windowSeconds * 0.15))stride = max(windowSeconds - overlapSeconds, 1)Window
kcovers[k*stride, k*stride + windowSeconds]; a cue belongs to windowkwhen it intersects that interval (cue.startTime < end && cue.endTime > start), so cues are atomic and boundary-straddling cues appear in both neighbouring windows, keeping abuse across a boundary scored intact. Asset duration comes from the duration already computed ingetModerationScores(minofgetVideoTrackDurationSecondsFromAsset/getAssetDurationSecondsFromAsset), falling back to the last cue'sendTimewhen missing/0. Empty windows (silence) are skipped, and two consecutive windows with the exact same cue set are deduped to avoid a redundant request. A rare safety guard splits any single window whose joined text would exceed theTRANSCRIPT_WINDOW_MAX_UTF16_CODE_UNITS(10k) ceiling into sub-windows under the cap (cues stay atomic, each carrying its own cue span). Consecutive windows' reported[startTime, endTime]ranges may overlap by ~overlapSecondsby design.Array-batched OpenAI requests (replaces one-request-per-window):
requestOpenAITranscriptModerationpacks windows into batches (capped atTRANSCRIPT_BATCH_MAX_UTF16_CODE_UNITS = 100,000combined chars andTRANSCRIPT_BATCH_MAX_ITEMS = 100items per request) and sends each batch as a single/v1/moderationsPOST with an arrayinput;results[i]is mapped back to windowi.callOpenAIModerationApinow acceptsstring[]input. Fallback: if a batch is rejected with a400(too large) and holds more than one window, it is split in half and retried recursively down to a single window. The existing 429/5xx retry/backoff insidecallOpenAIModerationApiis reused; a window that still fails emits itsstartTime/endTimewithsexual: 0, violence: 0, error: true, errorMessage. Concurrency (processConcurrently/maxConcurrent) now runs across batches.Tunable params: a new optional
transcriptWindowing?: { targetWindowCount?, minWindowSeconds?, maxWindowSeconds?, overlapFraction?, minOverlapSeconds? }onModerationOptionsoverrides the module-levelDEFAULT_TRANSCRIPT_WINDOWING(defaultstargetWindowCount 40/minWindowSeconds 20/maxWindowSeconds 120/overlapFraction 0.15/minOverlapSeconds 5), threaded throughgetModerationScoresinto the windowing helper.Audio-only path: windowed transcript results go into
transcriptScores;thumbnailScoresis[];mode === "transcript";transcriptModeration === { requested: true, status: "completed" }(the audio-only fetch keeps its existingrequired: truethrow on missing transcript, so a skipped status is unreachable there by design).Video
includeTranscriptpath: windowed transcript results go intotranscriptScoresalongside populatedthumbnailScoreswhen moderation ran; when it was skipped,transcriptScoresis[]andtranscriptModeration.status === "skipped"with a machine-readableskipReason. Provider guard (openai-only) preserved as a throw — it's caller error, not a data condition.maxScores/exceedsThresholdtake the max ofsexual/violenceacross boththumbnailScoresand alltranscriptScoreswindows; the all-failed guard considers both arrays. SameDEFAULT_THRESHOLDS({ sexual: 0.8, violence: 0.8 }).Coverage stays thumbnail-only. Audio-only results (zero thumbnails) stay confident —
isLowConfidenceis driven by transcript-window success when there are no thumbnails.modeis"thumbnails" | "transcript" | "combined":"transcript"for audio-only,"combined"when both arrays are non-empty,"thumbnails"otherwise. Independent of the newtranscriptModeration.status.docs/API.md— documented the dynamic, overlapping, duration-scaled windowing (clamp(duration / 40, 20s, 120s), ~15% / min-5s overlap), the array-batched requests with split-and-retry on 400, thetranscriptWindowingtuning option and its defaults, the newtranscriptModerationaudit field with everystatus/skipReasoncombination, and noted thattranscriptScoresentries may have slightly overlapping time ranges by design. Replaced "skipped silently" wording with the new auditable-status description.tests/unit/moderation-coverage.test.ts— mock-based unit tests (mock the.vttfetch + OpenAI fetch): (a) video withoutincludeTranscript→transcriptModeration === { requested: false, status: "not_requested" }; (b) video +includeTranscript: trueproducestranscriptScorescarryingstartTime/endTime, nochunkIndex, high transcriptsexual(0.95) raisesmaxScores/exceedsThreshold,mode === "combined",transcriptModeration.status === "completed"; (c) overlapping windows; (d) dynamic sizing (directbuildTranscriptWindowsunit); (e) boundary cue appears in two windows; (f) array batching; (g) 400 triggers split-and-retry; (h) no ready text track →transcriptModeration.status === "skipped"withskipReason: "no_ready_text_track", emptytranscriptScores,mode === "thumbnails"; (i) header-only VTT (no cues) →transcriptModeration.status === "skipped"withskipReason: "no_cues"; (j) non-openai provider throws; (k) audio-only produces windowedtranscriptScores, emptythumbnailScores,mode === "transcript", not low-confidence,transcriptModeration.status === "completed".tests/integration/moderation.test.ts— assertions remain shape-compatible (startTime/endTime, nochunkIndex); network behavior unchanged.Breaking change
This is a breaking change to
ModerationResult(pre-1.0, acceptable): audio-only transcript scores moved out ofthumbnailScoresinto the newtranscriptScoresarray, the synthetictranscript:URL convention is removed, transcript scores are now time-windowed (startTime/endTime) rather than chunk-indexed, and every result now carries the requiredtranscriptModeration: TranscriptModerationStatusfield. Approved by Phil.Note for reviewers
There is an in-flight branch
pc/variable-granularity-for-moderationalso touching moderation. This PR is intentionally based onmain, not that branch — please rebase intentionally to resolve any overlap.