Surface the base/source media a user attached to a blocked generation in the moderator restriction-review UI, so a deepfake against a real photo can be told apart from a transform of AI content. - Capture a compact media projection (`inputImages`, `inputVideo`) off the validated graph `data` at audit time — no workflow build, no reorder — and store it inline on `BlockedPromptEntry` so it rides the existing Redis → UserRestriction.triggers snapshot (JSON column; no Postgres migration). - Render an "Input Media" section in the restriction TriggerCard: input-image thumbnails, source-video thumbnail, and a link to the remixed image. - Durability: add `inputImages Array(String)` / `inputVideo Nullable(String)` to the ClickHouse `prohibitedRequests` table (ALTER already applied), written by the tracker and restored on cold-start reseed + backfill so captured media survives a sysRedis wipe. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9.9 KiB
Generation Restriction Review — Capturing Input/Base Images
Status: Implemented (pending runtime verification)
ClickUp: 868k7bpgt — "Show input/base images in generation restriction review UI"
Author: briant (with Claude)
Date: 2026-07-09 (revised after design review + the sourceImage → images consolidation)
1. The ask
In the moderator restriction-review UI (/moderator/generation-restrictions), surface the
input/base image(s) a user attached to a flagged generation job, next to the prompt and output
image. Motivation: prompts often reference a base image ("use this base image and make them
nude"), and the moderator can't currently tell a deepfake against a real person's photo apart
from transforming AI-generated content — very different severity.
2. The real problem (broader)
We want to capture enough of the blocked generation to review it faithfully and be robust to
workflow variance — different workflows attach media differently. A hand-picked flat schema
(prompt, negativePrompt, imageId, remixOfId) has been chasing each new ecosystem.
Simplification from the recent sourceImage → images consolidation (merged): every v2
generation graph now exposes its input images as a single images array (plus video for
vid2vid). So the "media variance" is largely gone — we no longer need to store a whole workflow to
be shape-agnostic. A small, uniform projection off the graph output covers it.
3. Current architecture
Trigger data for a restriction flows through three stores:
- Redis —
generation:blocked-prompts:{userId}(sysRedis), 30-day TTL. A transient pre-mute accumulator: each blocked attempt pushes oneBlockedPromptEntry; the list length is the strike count toward auto-mute. - Postgres —
UserRestriction.triggers(JSON column). The durable, moderator-facing store. Written once, on auto-mute:reportProhibitedRequestreads the whole Redis list viagetBlockedPrompts, snapshots it intodbWrite.userRestriction.create({ triggers }), then clears Redis. This is what the review UI reads. - ClickHouse —
prohibitedRequests. A separate durable audit log (track.prohibitedRequest), holdingprompt, negativePrompt, source, remixOfId, userId, time.
The BlockedPromptEntry shape (Redis + snapshotted to Postgres)
interface BlockedPromptEntry {
prompt: string;
negativePrompt: string;
source: string; // 'Regex' | 'External'
category?: PromptTriggerCategory;
matchedWord?: string;
matchedRegex?: string; // in the type, but never written to Redis (only backfill sets it)
imageId: number | null; // documented "source image on remix" — never populated (dead field)
remixOfId: number | null; // set only for remixes (a Civitai Image.id)
time: string; // ISO timestamp — natural per-attempt key
}
The ClickHouse reseed invariant
The flat schema encodes an invariant: "a Redis BlockedPromptEntry is reconstructable from
ClickHouse prohibitedRequests." Two paths depend on it:
- Cold-start reseed —
seedBlockedPromptsFromClickHouserebuilds the Redis list from CH columns after a key expiry / sysRedis wipe. - Backfill —
backfillTriggersrebuildsUserRestriction.triggersfrom those same CH columns.
CH holds only the flat columns, so any field we add to the entry that is not in CH will be missing on reseeded/backfilled triggers. That's acceptable for a go-forward review aid (see §7), and optionally closeable with a single bounded CH column (§6, optional).
4. Live data (sysRedis generation:blocked-prompts:*, 30-day window)
Measured 2026-07-09:
| Metric | Value |
|---|---|
| Keys (users) | 5,279 (103 marker-only → 5,176 with real blocks) |
| Total blocked-prompt entries | 15,546 |
| Users at/over mute threshold (8+) | 153 |
| Median blocks/user | 2 · Max 76 · Avg entry ~1,275 bytes |
Field fill-rates (of 3,003 sampled entries): prompt 100% · negativePrompt 68% ·
category/matchedWord 85% · matchedRegex 0% · imageId 0% (dead) ·
remixOfId 36% (already a renderable Civitai image for over a third of blocks).
Implications: imageId is dead; remixOfId already covers ~36% of cases at zero new cost; the
other ~64% (txt2img, or img2img via uploaded blobs) have no image reference today. Only ~153 of
5,176 users (≈3%) ever get muted and reviewed.
5. Key findings (from design review)
- The media URLs are already in
dataat audit time — no build, no reorder. IngenerateFromGraph,validateInputproducesdata(the validated graph output) before the audit throws;data.images[].url/data.video.urlare right there. We do not need to build or capture the assembled workflow. - The full-workflow / reorder idea was rejected. Building the workflow before the audit (to
capture it) is (a) an evasion vector —
createWorkflowStepsFromGraphthrows on POI/bad resource/missing image, so moving it ahead of the audit lets a malformed request skip strike-counting; and (b) pointless — the workflow just wraps the same URLs, so it buys zero durability over storing the URLs directly (the bytes live at the URL either way). - Store the projection inline in
BlockedPromptEntry, keyed by nothing. It rides the existing Redis → Postgres snapshot for free and avoids a separate keyed store (a(userId, time)join key is not unique — millisecondtimecollides on parallel submits). - Go-forward only. Reseeded/backfilled triggers won't have the media unless we also add it to ClickHouse (§6 optional).
- Blob-URL expiry is inherent. We store URLs, not bytes; a stored orchestrator/upload URL can 404 by review time. This is true of any approach — confirm the upload bucket's retention exceeds mute→review latency; not a differentiator.
- Prior art: the "View Generations" drawer (
generation-restrictions.tsx,UserGenerationsDrawer) already surfaces the user's successful generations. This capture only fills the blocked-attempt gap.
6. Plan
Capture a compact media projection off data, inline in the trigger. No reorder, no new store.
6a. Server capture
- Extend
BlockedPromptEntry(andAuditPromptOptions) with the projection:inputImages?: string[]; // base/source image URLs attached to the job inputVideo?: string; // source video URL (vid2vid) - In
generateFromGraph, extract fromdataand pass intoauditPromptServer:data.images?.map(i => i.url)(+data.sourceImage?.urldefensively for any legacy path) anddata.video?.url.auditPromptServerwrites them onto theblockedEntryit already builds — so they ride the existing Redis push → Postgres snapshot with no new store and no change to the mute-counting or reseed logic. - Footprint: a handful of URL strings (~hundreds of bytes) per entry — negligible next to the existing ~1.3KB, and it only lands durably for the ~153 muted users/30d via the existing snapshot.
6b. UI render (generation-restrictions.tsx)
- Add the projection fields to the local
RestrictionTriggertype. - In
TriggerCard, render the input images alongside the prompt: the capturedinputImages/inputVideoURLs directly, plusremixOfId(already ~36% populated) viaEdgeImage.
6c. ClickHouse durability (implemented)
Mirror the media into prohibitedRequests so reseeded/backfilled triggers keep it after a sysRedis
wipe. default.prohibitedRequests is SharedMergeTree (ClickHouse Cloud), so a single additive
ALTER replicates automatically — no ON CLUSTER / Distributed juggling.
Applied (2026-07-10):
ALTER TABLE prohibitedRequests
ADD COLUMN IF NOT EXISTS inputImages Array(String) DEFAULT [],
ADD COLUMN IF NOT EXISTS inputVideo Nullable(String);
Wired through: tracker.prohibitedRequest writes them; reportProhibitedRequest forwards them;
seedBlockedPromptsFromClickHouse (cold-start reseed) and backfillTriggers (router) SELECT and
restore them onto the rebuilt BlockedPromptEntry. Existing rows read the column defaults (empty),
matching reality — historical requests have no captured media.
6d. Rejected
- Capture the built workflow — unsafe reorder (evasion) + zero durability gain (§5.2).
- Separate
blocked-workflow:{userId}:{time}Redis store + promote-at-mute — unnecessary once the payload is just a few URLs that fit inline; the(userId, time)key isn't unique anyway. - Store the whole
data— carries resources/params the moderator doesn't need; theimagesconsolidation means a projection already covers the variance.
7. Caveats
- Go-forward only — media is captured from now on; requests logged before this change have no media (the CH columns default to empty). §6c keeps go-forward media across a Redis reseed/backfill.
- Blob-URL expiry — stored URLs may 404 by review time; depends on upload-bucket retention.
- Only ~3% of blocked users are ever reviewed, but inline capture is cheap enough that per-attempt storage is a non-issue (unlike the rejected full-workflow approach).
8. Implementation checklist
BlockedPromptEntry+AuditPromptOptions: addinputImages?: string[],inputVideo?: stringgenerateFromGraph: extract media fromdata, pass toauditPromptServerauditPromptServer: write the projection ontoblockedEntrygeneration-restrictions.tsx: extendRestrictionTrigger, render images +remixOfIdinTriggerCard- ClickHouse columns + tracker/report/reseed/backfill wiring (§6c)
- Drive the restriction UI to confirm images render (runtime verification)