feat(moderation): moderate transcript text for video assets#208
Draft
claude[bot] wants to merge 4 commits into
Draft
feat(moderation): moderate transcript text for video assets#208claude[bot] wants to merge 4 commits into
claude[bot] wants to merge 4 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, and moves the transcript into dynamic, overlapping time windows whose size scales with the asset's duration.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.sexual+violenceonly, thresholds0.8/0.8(unchangedDEFAULT_THRESHOLDS).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).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".Video
includeTranscriptpath: windowed transcript results go intotranscriptScoresalongside populatedthumbnailScores(empty[]if no caption track / no cues). Provider guard (openai-only) preserved.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.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, and noted thattranscriptScoresentries may have slightly overlapping time ranges by design. Removed the stale fixed char-only chunking text.tests/unit/moderation-coverage.test.ts— mock-based unit tests (mock the.vttfetch + OpenAI fetch): (a) video +includeTranscript: trueproducestranscriptScorescarryingstartTime/endTime, nochunkIndex,thumbnailScoresimages-only, high transcriptsexual(0.95) raisesmaxScores/exceedsThreshold,mode === "combined"; (b) overlapping windows — at least two windows whose consecutive time ranges overlap; (c) dynamic sizing via a directbuildTranscriptWindowsunit test (a long duration yields fewer/larger windows than a short duration for the same cue density); (d) a boundary cue appears in two consecutive windows; (e) array batching — multiple windows packed into one request'sinputarray; (f) a forced 400 triggers split-and-retry producing per-window results; (g) no ready text track produces emptytranscriptScores,mode === "thumbnails"; (h) non-openai provider throws; (i) audio-only produces windowedtranscriptScores, emptythumbnailScores,mode === "transcript", not low-confidence.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, and transcript scores are now time-windowed (startTime/endTime) rather than chunk-indexed. 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.