From 6471abff78bec6acbf2396f1d114a73c419ef143 Mon Sep 17 00:00:00 2001 From: briant Date: Thu, 17 Sep 2026 10:49:02 -0600 Subject: [PATCH] feat(ingestion): scan images with imageScanning behind a Flipt flag ClickUp 868m5wp5r. Image and video ingestion can submit the single orchestrator `imageScanning` step instead of `wdTagging` + `mediaRating`, gated by the Flipt flag `image-ingestion-image-scanning` (per image id, off by default). Flag off, the submitted workflow is unchanged. - `/api/webhooks/image-scan-result` stays the only callback. It fetches the workflow and routes on its step types, so both shapes can be in flight across a flag flip. - Stages both pipelines share move unchanged to `image-scan-pipeline.ts`. The legacy service keeps its own parsing and flow. - New `image-scanning-result.service.ts` reads the imageScanning output directly (images and video frames), keeps only general tags, and records csam without acting on it, as legacy does. - The new pipeline logs to Axiom as `image-scanning-result` / `image-scanning-ingestion`, submits under `image_scan_submitted_total{lane="imageScanning"}`, and writes scanner audit rows as version '2'. - Remove the non-orchestrator scanner path: the webhook's legacy body handling, the `IMAGE_SCANNER_NEW` Redis toggle, `ingestImageBulk`, `image.ingestArticleImages`, `/api/webhooks/reingest-images`, `/api/internal/add-missing-phash`, `/api/mod/scan-images`, and the `IMAGE_SCANNING_ENDPOINT` / `IMAGE_SCANNING_MODEL` env vars. - Bump `@civitai/orchestration-client` to 0.2.0-beta.106 for the imageScanning types. Keep the flag off until a deploy has fully rolled out: pods on the previous build cannot read imageScanning callbacks. Co-Authored-By: Claude Opus 5 (1M context) --- .env-example | 2 - docs/article-content-scanning.md | 21 +- docs/article-scanning-rollout.md | 2 +- docs/db-queries-moderation-port-checklist.md | 6 +- docs/features/scanner-pending-migrations.md | 4 +- docs/features/scanner-prompt-tuning.md | 14 +- docs/image-scan-result-webhook-failures.md | 10 +- docs/model3d-thumbnail-nsfw-propagation.md | 5 +- docs/moderator-app/post-migration-backlog.md | 2 +- .../retool-migration-handover-detail.md | 3 +- eslint-local-rules.js | 1 - package.json | 2 +- packages/civitai-redis/src/client.ts | 9 - packages/civitai-telemetry/src/client.ts | 2 +- pnpm-lock.yaml | 10 +- .../image-scan-result.routing.test.ts | 142 ++ .../api/webhooks/image-scan-result.test.ts | 538 +++---- src/env/server-schema.ts | 3 - src/pages/api/internal/add-missing-phash.ts | 78 - src/pages/api/mod/scan-images.ts | 89 -- src/pages/api/webhooks/image-scan-result.ts | 1326 +---------------- src/pages/api/webhooks/reingest-images.ts | 34 - src/server/flipt/client.ts | 5 + src/server/games/new-order/pool-quotas.ts | 2 +- src/server/jobs/image-ingestion.ts | 15 +- src/server/redis/buffer-decode.ts | 4 +- src/server/routers/image.router.ts | 5 - .../image-scanner-flag.buffer.test.ts | 51 - .../image-scanning-result.service.test.ts | 435 ++++++ .../poi-checks-strip-benign-phrases.test.ts | 16 +- .../scanner-audit.image-scanning.test.ts | 88 ++ src/server/services/article.service.ts | 2 +- .../services/games/new-order.service.ts | 2 +- src/server/services/image-scan-pipeline.ts | 962 ++++++++++++ .../services/image-scan-result.service.ts | 1018 +------------ src/server/services/image-scanner-flag.ts | 25 - .../services/image-scanning-result.service.ts | 310 ++++ src/server/services/image.service.ts | 331 +--- .../createModelFileScanRequest.test.ts | 136 ++ .../orchestrator/orchestrator.service.ts | 198 ++- .../services/orchestrator/promptAuditing.ts | 2 +- src/server/services/scanner-audit.service.ts | 89 ++ src/server/utils/webhook-debounce.ts | 5 +- tests/preview-post-upload.spec.ts | 5 +- 44 files changed, 2650 insertions(+), 3359 deletions(-) create mode 100644 src/__tests__/pages/api/webhooks/image-scan-result.routing.test.ts delete mode 100644 src/pages/api/internal/add-missing-phash.ts delete mode 100644 src/pages/api/mod/scan-images.ts delete mode 100644 src/pages/api/webhooks/reingest-images.ts delete mode 100644 src/server/services/__tests__/image-scanner-flag.buffer.test.ts create mode 100644 src/server/services/__tests__/image-scanning-result.service.test.ts create mode 100644 src/server/services/__tests__/scanner-audit.image-scanning.test.ts create mode 100644 src/server/services/image-scan-pipeline.ts delete mode 100644 src/server/services/image-scanner-flag.ts create mode 100644 src/server/services/image-scanning-result.service.ts diff --git a/.env-example b/.env-example index 839029cc34..8da28d0459 100644 --- a/.env-example +++ b/.env-example @@ -207,8 +207,6 @@ SHOPIFY_ADMIN_TOKEN= FLIPT_URL="" FLIPT_FETCHER_SECRET=placeholder -IMAGE_SCANNER_NEW=false - # App Blocks β€” per-app generation spend/velocity ABSOLUTE CEILINGS (incident knobs). # πŸ”΄ These are UPPER BOUNDS, not the limit an app receives. Each app's actual # ceilings come from its server-owned `spendTier` (+ any moderator per-app diff --git a/docs/article-content-scanning.md b/docs/article-content-scanning.md index a810e65447..dbd2df9564 100644 --- a/docs/article-content-scanning.md +++ b/docs/article-content-scanning.md @@ -438,22 +438,12 @@ Both use `GREATEST` semantics (never lower, only raise) and lock `userNsfwLevel` #### 4. Webhook Integration -**File**: `src/pages/api/webhooks/image-scan-result.ts` (image scanning) +**File**: `src/pages/api/webhooks/image-scan-result.ts` reads the flag; the scan processors (`image-scan-result.service.ts`, `image-scanning-result.service.ts`) do the fan-out ```typescript -// Feature flag gated -const featureFlags = getFeatureFlagsLazy({ req }); -if (featureFlags.articleImageScanning) { - // Find articles using this image - const articleConnections = await dbWrite.imageConnection.findMany({ - where: { imageId: image.id, entityType: 'Article' }, - }); - - // Debounced updates - for (const { entityId } of articleConnections) { - await debounceArticleUpdate(entityId); - } -} +articleImageScanning: getFeatureFlagsLazy({ req }).articleImageScanning, // webhook β†’ processor input +// in the processor, once the verdict is written: +if (articleImageScanning) await fanOutArticleImageUpdates(imageId); // content + cover articles β†’ debounceArticleUpdate ``` **File**: `src/pages/api/webhooks/text-moderation-result.ts` (text moderation) @@ -993,7 +983,8 @@ console.log('Article Image Scanning:', flags.articleImageScanning); - `src/server/services/article.service.ts` - Main article operations, image linking, scan status - `src/server/services/article-content-cleanup.service.ts` - Image/media extraction from content (server-side) - `src/server/services/nsfwLevels.service.ts` - NSFW level calculation (cover + content images) -- `src/pages/api/webhooks/image-scan-result.ts` - Image scan webhook handler +- `src/pages/api/webhooks/image-scan-result.ts` - Image scan webhook handler (reads the flag, routes by workflow step) +- `src/server/services/image-scan-result.service.ts`, `image-scanning-result.service.ts`, `image-scan-pipeline.ts` - scan processing + article fan-out - `src/server/utils/webhook-debounce.ts` - Redis-based debouncing logic - `src/utils/article-helpers.ts` - Image extraction (client-side), shared helpers diff --git a/docs/article-scanning-rollout.md b/docs/article-scanning-rollout.md index 8998e001b0..4be09e1a17 100644 --- a/docs/article-scanning-rollout.md +++ b/docs/article-scanning-rollout.md @@ -391,7 +391,7 @@ articleImageScanning: [], // was ['public'] ``` **Effect**: -- New articles still save, still submit to text moderation, but `debounceArticleUpdate` in `image-scan-result.ts` short-circuits +- New articles still save, still submit to text moderation, but the scan processors skip `fanOutArticleImageUpdates`, so no article update is queued - Articles already in `Processing` will **not** auto-transition to `Published` β€” they need manual intervention or the flag re-enabled - Existing NSFW levels stay as-is diff --git a/docs/db-queries-moderation-port-checklist.md b/docs/db-queries-moderation-port-checklist.md index 49383427f4..98283d274b 100644 --- a/docs/db-queries-moderation-port-checklist.md +++ b/docs/db-queries-moderation-port-checklist.md @@ -187,11 +187,11 @@ one) and **Net-new** (moderation the moderator app doesn't cover β€” new domains ### 13. knights "down-leveled" review (`services/image.service.ts`) -- [ ] `getDownleveledImages` (ClickHouse `knights_new_order_downleveled` + PG join) + `addToNewOrderQueue` +- [ ] `getDownleveledImages` (ClickHouse `knights_new_order_downleveled` + PG join) (`image.service.ts`) + `addToNewOrderQueue` (`image-scan-pipeline.ts`) -### 14. scan-result ingestion pipeline internals (`services/image-scan-result.service.ts`) +### 14. scan-result ingestion pipeline internals (`services/image-scan-pipeline.ts`, `services/image-scan-result.service.ts`) -- [ ] `resolveScanOutcome`, `auditScanResults`, `markImageScanError`, `blockImageFromRating`, `getAssociatedEntities`, `evaluateImageModRules`, `isExemptFromAiVerification`, `processTags` +- [ ] `resolveScanOutcome`, `auditScanResults`, `markImageScanError`, `getAssociatedEntities`, `evaluateImageModRules`, `processTags` (pipeline); `blockImageFromRating`, `isExemptFromAiVerification` (image-scan-result.service) --- diff --git a/docs/features/scanner-pending-migrations.md b/docs/features/scanner-pending-migrations.md index 4fb6aa4f81..db2837d063 100644 --- a/docs/features/scanner-pending-migrations.md +++ b/docs/features/scanner-pending-migrations.md @@ -43,8 +43,8 @@ ORDER BY (scanner, label, contentHash, version); Column semantics: -- **`version`** (per-label) β€” policyHash from `result.policyHash` for XGuard scans; hardcoded `'1'` for image scans until the orchestrator team surfaces per-result version info on the `mediaRating` step. -- **`modelVersion`** (workflow-level) β€” scanner/model version stamp, sourced from `workflow.metadata.version`. Hardcoded `'1'` everywhere today. +- **`version`** (per-label) β€” policyHash from `result.policyHash` for XGuard scans; for image scans a fixed `'1'` (`mediaRating` lane) or `'2'` (`imageScanning` lane) until the orchestrator surfaces per-result version info. +- **`modelVersion`** (workflow-level) β€” scanner/model version stamp, sourced from `workflow.metadata.version` (default `'1'`); image scans write `'1'` or `'2'` by lane, as above. Both kept as separate columns so when the orchestrator starts returning per-label version info on `mediaRating`, we can populate `version` independently of `modelVersion`. diff --git a/docs/features/scanner-prompt-tuning.md b/docs/features/scanner-prompt-tuning.md index a431f089a4..8f4abee93f 100644 --- a/docs/features/scanner-prompt-tuning.md +++ b/docs/features/scanner-prompt-tuning.md @@ -25,7 +25,7 @@ What gets authored in phase 1: The orchestrator stamps each evaluation with a `policyHash` derived from the exact policy text. That hash becomes the `version` column on `scanner_label_results`, which is what makes A/B comparison in phase 3 work without civitai-side bookkeeping. -For image scanners, "defining a label" means adding a new `include*` flag to the `mediaRating` step input and a corresponding output field (see [image-scan-result.service.ts](src/server/services/image-scan-result.service.ts) and the `TagSource` enum). Versioning is currently a `'1'` placeholder until the orchestrator surfaces per-result version info. +For image scanners, "defining a label" means adding a new `include*` flag to the `mediaRating` step input and a corresponding output field (see [image-scan-result.service.ts](src/server/services/image-scan-result.service.ts) and the `TagSource` enum). Versioning is a fixed stamp until the orchestrator surfaces per-result version info: `'1'` for the `mediaRating` lane, `'2'` for the `imageScanning` lane (different models, so the two must not aggregate). ### Phase 2 β€” Provide a dataset @@ -217,12 +217,12 @@ The `topK` distributions, bounding boxes, face geometry, and confidences for non `wdTagging` and `mediaHash` are unchanged. -**Code changes required in [image-scan-result.service.ts](src/server/services/image-scan-result.service.ts):** +**Code changes required in [image-scan-result.service.ts](src/server/services/image-scan-result.service.ts) (step types) and [image-scan-pipeline.ts](src/server/services/image-scan-pipeline.ts) (tags + audit):** -1. Extend the `MediaRatingStep` TypeScript type (line 74) β€” add the four new optional output fields. -2. Extend `tagsWithSource` (line 251) to emit `minor`/`ai`/`anime` tags from the new fields when present. +1. Extend the `MediaRatingStep` TypeScript type in `image-scan-result.service.ts` β€” add the four new optional output fields. +2. Extend `tagsWithSource` (`buildAndInsertScanTags` in `image-scan-pipeline.ts`) to emit `minor`/`ai`/`anime` tags from the new fields when present. 3. Add `AiRecognition` + `AnimeRecognition` to the `TagSource` Prisma enum (or accept lossy provenance and reuse `Computed`). -4. Decide whether `auditScanResults` branches on the new `MinorDetection`-sourced `minor` tag (stronger signal than the hand-curated `tagsNeedingReview` list) or just appends `minor` to that list. +4. Decide whether `auditScanResults` (`image-scan-pipeline.ts`) branches on the new `MinorDetection`-sourced `minor` tag (stronger signal than the hand-curated `tagsNeedingReview` list) or just appends `minor` to that list. 5. Forward the full untruncated `mediaRating.output` to the audit log so the per-detection / topK / non-triggered confidences are preserved for tuning. --- @@ -254,13 +254,13 @@ The `topK` distributions, bounding boxes, face geometry, and confidences for non ### Image ingestion -- **Webhook**: [src/pages/api/webhooks/image-scan-result.ts](src/pages/api/webhooks/image-scan-result.ts) β€” handles legacy POST + new orchestrator workflow format +- **Webhook**: [src/pages/api/webhooks/image-scan-result.ts](src/pages/api/webhooks/image-scan-result.ts) β€” orchestrator workflow/job events only; routes `imageScanning` workflows to [image-scanning-result.service.ts](src/server/services/image-scanning-result.service.ts) and `wdTagging` + `mediaRating` workflows to `processImageScanWorkflow`. Which step is submitted is chosen in `createImageIngestionRequest` by the Flipt flag `image-ingestion-image-scanning`. - **Processor**: [image-scan-result.service.ts](src/server/services/image-scan-result.service.ts) β†’ `processImageScanWorkflow` β€” parses `wdTagging`/`mediaRating`/`mediaHash` steps. The `mediaRating` type definition is what needs to be extended for the new fields. - **Storage**: - `Image` table: `nsfwLevel: Int`, `minor: Boolean`, `poi: Boolean`, `needsReview: String?`, `blockedFor: String?`, `ingestion: ImageIngestionStatus`, `scanJobs: Json?` - `TagsOnImageDetails`: `automated`, `disabled`, `needsReview`, `confidence`, `source: TagSource` - `ImageTagForReview`: review queue (per-image, per-tag) -- **Review trigger conditions** (current logic in [image-scan-result.ts](src/pages/api/webhooks/image-scan-result.ts:697-733)): `child-10/13/15` + realistic, POI word-list match, `nsfwLevel === Blocked`, moderator-specific tags. +- **Review trigger conditions** (current logic in `auditScanResults`, [image-scan-pipeline.ts](src/server/services/image-scan-pipeline.ts)): `child-10/13/15` + realistic, POI word-list match, `nsfwLevel === Blocked`, moderator-specific tags. - **Tag rules**: [src/server/utils/tag-rules.ts](src/server/utils/tag-rules.ts) β€” replacements, appends, computed combos. ### Cross-cutting diff --git a/docs/image-scan-result-webhook-failures.md b/docs/image-scan-result-webhook-failures.md index 86a9b86555..5028dd11df 100644 --- a/docs/image-scan-result-webhook-failures.md +++ b/docs/image-scan-result-webhook-failures.md @@ -4,6 +4,8 @@ Investigation of errors logged by the `/api/webhooks/image-scan-result` handler `processImageScanResult` (`src/server/services/image-scan-result.service.ts`), which became the default scan-result path on 2026-07-22. +> **2026-09 note:** `processImageScanResult` is gone. The webhook now dispatches to `processImageScanWorkflow` (wdTagging + mediaRating) or `processImageScanningWorkflow` (imageScanning), and the shared stages moved to `image-scan-pipeline.ts`. Errors log as `name: 'image-scan-result'` or `'image-scanning-result'` by lane; submit failures log as `'image-ingestion'` or `'image-scanning-ingestion'`. + ## Where failures are logged | Signal | Dataset | Shape | @@ -181,9 +183,9 @@ await signalClient This is already the house pattern β€” `src/server/services/referral.service.ts` and `src/server/auth/session-invalidation.ts` both catch-and-log around `signalClient.send`. -Apply the same change to the legacy path at `src/pages/api/webhooks/image-scan-result.ts` (the -unguarded `await signalClient.send` in the `data.ingestion !== 'Blocked'` branch) β€” that call is -why timeout/signal-500 errors also appear on days before the 07-22 cutover. +The legacy (non-orchestrator) path in `src/pages/api/webhooks/image-scan-result.ts` that also sent +this signal unguarded has since been removed; it is why timeout/signal-500 errors also appear on days +before the 07-22 cutover. ### Tradeoff @@ -326,5 +328,5 @@ Images rejected as "too large" end up terminally `Error` and unscanned. That is downscaling before submit, or raising the orchestrator's limit. Worth a follow-up decision. `markImageScanSubmitFailure` duplicates the jsonb shape written by `markImageScanError` in -`image-scan-result.service.ts`. They differ (submit failures have no `workflowId`), but the two +`image-scan-pipeline.ts`. They differ (submit failures have no `workflowId`), but the two should probably share a helper before a third caller appears. diff --git a/docs/model3d-thumbnail-nsfw-propagation.md b/docs/model3d-thumbnail-nsfw-propagation.md index a31f47cd59..72ddb75e91 100644 --- a/docs/model3d-thumbnail-nsfw-propagation.md +++ b/docs/model3d-thumbnail-nsfw-propagation.md @@ -166,8 +166,7 @@ replica-lag miss, so a plain `dbRead` is safe. branches). When there's no post it does a `dbRead` lookup by `thumbnailImageId`, then calls `updateModel3DNsfwLevels` directly (a **synchronous** recompute β€” not a JobQueue enqueue). - Call sites (each just passes `{ imageId, postId }`, replacing the removed `queueModel3D…`): - - `src/pages/api/webhooks/image-scan-result.ts` β€” Scanned + Blocked - - `src/server/services/image-scan-result.service.ts` β€” Scanned + Blocked branches + - `src/server/services/image-scan-pipeline.ts` `applyIngestionSideEffects` β€” Scanned + Blocked branches (shared by both scan pipelines; the legacy webhook body is gone) - `src/server/services/image.service.ts` `updateImageNsfwLevel` β€” moderator branch (also added `postId` to the `findUnique` select) @@ -195,7 +194,7 @@ Model3D trigger) and had the image trigger read it (zero lookup). A review found soundness gap: `Image.metadata` is rewritten **wholesale** in several places, so the flag could be silently and permanently dropped: -- `src/server/services/image-scan-result.service.ts` (~line 735): `"metadata" = +- `src/server/services/image-scan-pipeline.ts` (`resolveScanOutcome`): `"metadata" = COALESCE(::jsonb, "metadata")` **replaces** the column when a mod rule carries a metadata payload β€” on the scan path, same UPDATE as the nsfwLevel change. - `src/server/services/image.service.ts` `updateImageNsfwLevel` (~line 6747) and the diff --git a/docs/moderator-app/post-migration-backlog.md b/docs/moderator-app/post-migration-backlog.md index 9cdc119302..7da5eac432 100644 --- a/docs/moderator-app/post-migration-backlog.md +++ b/docs/moderator-app/post-migration-backlog.md @@ -111,7 +111,7 @@ Re-read off the live ticket 2026-08-21; the section above had captured the middl every gallery image via `UPDATE "Image" SET minor = …, poi = …`, queueing those ids into the image index. `poi` is on `modelUpsertSchema` beside `minor` and `upsertModel` calls that fan-out, so flipping poi today already does all of it. Image-level `poi` also excludes from the image search - index and is re-derived from the parent model at scan time (`image-scan-result.service.ts:1089`). + index and is re-derived from the parent model at scan time (`getAssociatedEntities` in `image-scan-pipeline.ts`). What `poi` genuinely lacks against `minor` is narrower, and is a build list rather than a question: **no dedicated setter / `moderatorProcedure`**, **no snapshot** (so no rollback), **absent from diff --git a/docs/moderator-app/retool-migration-handover-detail.md b/docs/moderator-app/retool-migration-handover-detail.md index 2f24a94aa1..dfb9c60c88 100644 --- a/docs/moderator-app/retool-migration-handover-detail.md +++ b/docs/moderator-app/retool-migration-handover-detail.md @@ -170,7 +170,8 @@ and builds; that is all that is established. Before any of it is trusted in prod ## 4. Known-open, decided or deferred - **`aiNsfwLevel` / `aiModel` exist in production but not in `schema.full.prisma`.** - `src/pages/api/webhooks/image-scan-result.ts` writes both, and Front Page Audit reads `aiNsfwLevel` + nothing in this repo writes them any more (the only writer was the legacy scanner branch of + `image-scan-result.ts`, removed), and Front Page Audit reads `aiNsfwLevel` through raw `sql` because it cannot be selected as a typed column. It is the scanner's own rating, and disagreement with `nsfwLevel` is the strongest signal that a row needs a human β€” so it is worth having. Either add both to the schema and regenerate, or accept the raw read as permanent. diff --git a/eslint-local-rules.js b/eslint-local-rules.js index f49dbc816d..035380501f 100644 --- a/eslint-local-rules.js +++ b/eslint-local-rules.js @@ -118,7 +118,6 @@ const IO_CALL_NAMES = new Set([ 'fetch', // image ingestion / scanner 'ingestImage', - 'ingestImageBulk', 'createImageIngestionRequest', // orchestrator 'submitWorkflow', diff --git a/package.json b/package.json index d6d80ca616..a76c32eddb 100644 --- a/package.json +++ b/package.json @@ -140,7 +140,7 @@ "@civitai/generation-metadata": "^0.3.0", "@civitai/moderation": "workspace:*", "@civitai/next-axiom": "^0.17.0", - "@civitai/orchestration-client": "0.2.0-beta.104", + "@civitai/orchestration-client": "0.2.0-beta.106", "@civitai/shared": "workspace:*", "@clavata/sdk": "^0.2.3", "@clickhouse/client": "^0.2.2", diff --git a/packages/civitai-redis/src/client.ts b/packages/civitai-redis/src/client.ts index 95bcd848dc..a8312e18bb 100644 --- a/packages/civitai-redis/src/client.ts +++ b/packages/civitai-redis/src/client.ts @@ -2064,15 +2064,6 @@ export const REDIS_SYS_KEYS = { CREATION_BLOCKED_TAGS: 'system:creation-blocked-tags', LIVE_FEATURE_FLAGS: 'system:live-feature-flags', SUSPICIOUS_AUDIT_MATCHES: 'system:suspicious-audit-matches', - /* - Runtime toggle for the new image ingestion path (createImageIngestionRequest - with the expanded mediaRating step). Read by image.service.ts before - routing to the new vs legacy scanner. Accepts '1'/'true' to enable, - '0'/'false' to disable. If the key is missing, the first call seeds it to - 'false' so the toggle is discoverable in Redis. Lets ops flip without a - deploy. - */ - IMAGE_SCANNER_NEW: 'system:image-scanner-new', /* Per-run image cap for the remove-deleted-user-images job. Set to '0' to pause the drain without a deploy. Missing key means the job's compiled diff --git a/packages/civitai-telemetry/src/client.ts b/packages/civitai-telemetry/src/client.ts index 14104fb50c..f9d024e3d4 100644 --- a/packages/civitai-telemetry/src/client.ts +++ b/packages/civitai-telemetry/src/client.ts @@ -586,7 +586,7 @@ export const imageScanWebhookCounter = registerCounterWithLabels({ export const imageScanSubmittedCounter = registerCounterWithLabels({ name: 'image_scan_submitted_total', - help: 'ingestImage() scan submissions by lane (new|legacy) and result (success|failed)', + help: 'ingestImage() scan submissions by lane (new = wdTagging+mediaRating | imageScanning) and result (success|failed)', labelNames: ['lane', 'result'] as const, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a2846dbdd..0b5acaf727 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,8 +71,8 @@ importers: specifier: ^0.17.0 version: 0.17.0(next@16.3.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(@types/node@24.13.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0)) '@civitai/orchestration-client': - specifier: 0.2.0-beta.104 - version: 0.2.0-beta.104 + specifier: 0.2.0-beta.106 + version: 0.2.0-beta.106 '@civitai/shared': specifier: workspace:* version: link:packages/civitai-shared @@ -2225,8 +2225,8 @@ packages: peerDependencies: next: ^12.1.4 || ^13 || ^14 - '@civitai/orchestration-client@0.2.0-beta.104': - resolution: {integrity: sha512-V7EKGBMi3YjtOfkxXOmogJhSbM83t9OCJOtxH6QSpwzsE7JZH85gtoMW6sSvF7pDLj9Ywim68Pmlq2M0Qd47sg==} + '@civitai/orchestration-client@0.2.0-beta.106': + resolution: {integrity: sha512-PfDmcOXhwgknRitkyeE21/oJMnu6+eDSYyLWL2UgdZScoi5adRnEhJNfV+nWIBHzKz62whwEW+BhG8uxTTzsZw==} engines: {git: '>=2.11.0', node: '>=18.0.0', npm: '>=7.19.0', yarn: '>=1.7.0'} '@clavata/sdk@0.2.3': @@ -13450,7 +13450,7 @@ snapshots: next: 16.3.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(@types/node@24.13.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0) whatwg-fetch: 3.6.20 - '@civitai/orchestration-client@0.2.0-beta.104': + '@civitai/orchestration-client@0.2.0-beta.106': dependencies: '@hey-api/client-fetch': 0.1.14 rfc6902: 5.2.0 diff --git a/src/__tests__/pages/api/webhooks/image-scan-result.routing.test.ts b/src/__tests__/pages/api/webhooks/image-scan-result.routing.test.ts new file mode 100644 index 0000000000..85edfaaddf --- /dev/null +++ b/src/__tests__/pages/api/webhooks/image-scan-result.routing.test.ts @@ -0,0 +1,142 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type * as ImageScanningResultService from '~/server/services/image-scanning-result.service'; + +const mocks = vi.hoisted(() => ({ + getWorkflow: vi.fn(), + captureJobFailureReason: vi.fn(), + processImageScanWorkflow: vi.fn(), + processImageScanningWorkflow: vi.fn(), +})); + +vi.mock('~/server/utils/endpoint-helpers', () => ({ + WebhookEndpoint: (handler: unknown) => handler, +})); +vi.mock('@civitai/client', () => ({ getWorkflow: mocks.getWorkflow })); +vi.mock('~/server/services/orchestrator/client', () => ({ internalOrchestratorClient: {} })); +vi.mock('~/server/services/feature-flags.service', () => ({ + getFeatureFlagsLazy: () => ({ articleImageScanning: true }), +})); +vi.mock('~/server/prom/client', () => ({ imageScanWebhookCounter: { inc: vi.fn() } })); +// Both pipelines import the whole ingestion stack; this suite pins only the routing between them. +vi.mock('~/server/services/image-scan-pipeline', () => ({ + captureJobFailureReason: mocks.captureJobFailureReason, + LEGACY_SCAN_LOG: { name: 'image-scan-result', source: 'image-scan-result.service' }, +})); +vi.mock('~/server/services/image-scan-result.service', () => ({ + processImageScanWorkflow: mocks.processImageScanWorkflow, +})); +// The real `isImageScanningWorkflow` makes the routing decision under test. +vi.mock('~/server/services/image-scanning-result.service', async (importOriginal) => ({ + ...(await importOriginal()), + processImageScanningWorkflow: mocks.processImageScanningWorkflow, +})); +vi.mock('~/server/services/scanner-audit.service', () => ({ recordImageScanningResult: vi.fn() })); +vi.mock('~/server/services/job-queue.service', () => ({ removeImageScanJobQueue: vi.fn() })); +vi.mock('~/server/services/orchestrator/orchestrator.service', () => ({ + computePerceptualHash: vi.fn(), +})); +vi.mock('~/server/utils/webhook-debounce', () => ({ fanOutArticleImageUpdates: vi.fn() })); + +import handler from '~/pages/api/webhooks/image-scan-result'; +import { loggingMock } from '~/__tests__/mocks/logging.mock'; + +const call = async (body: unknown) => { + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + send: vi.fn().mockReturnThis(), + }; + await (handler as unknown as (req: NextApiRequest, res: NextApiResponse) => Promise)( + { method: 'POST', body } as NextApiRequest, + res as unknown as NextApiResponse + ); + return res; +}; +const deliver = (status: string, steps: unknown[]) => { + mocks.getWorkflow.mockResolvedValue({ + data: { metadata: { imageId: 7 }, startedAt: 'start', completedAt: 'end', steps }, + }); + return call({ workflowId: 'wf', status }); +}; +const pipelineInput = (status: string, steps: unknown[]) => ({ + workflowId: 'wf', + status, + steps, + imageId: 7, + articleImageScanning: true, + startedAt: 'start', + completedAt: 'end', +}); + +describe('image-scan-result webhook routing', () => { + beforeEach(() => vi.clearAllMocks()); + + it('stashes a job failure reason without fetching the workflow', async () => { + const event = { workflowId: 'wf', jobId: 'job', reason: 'timed out' }; + const res = await call(event); + + expect(mocks.captureJobFailureReason).toHaveBeenCalledWith(event); + expect(mocks.getWorkflow).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('sends an imageScanning workflow to the imageScanning pipeline, failed or not', async () => { + const steps = [{ $type: 'imageScanning', status: 'failed' }]; + await deliver('failed', steps); + + expect(mocks.processImageScanningWorkflow).toHaveBeenCalledTimes(1); + expect(mocks.processImageScanningWorkflow).toHaveBeenCalledWith(pipelineInput('failed', steps)); + expect(mocks.processImageScanWorkflow).not.toHaveBeenCalled(); + }); + + it('sends a wdTagging + mediaRating workflow to the legacy pipeline', async () => { + const steps = [ + { $type: 'wdTagging', output: { tags: {} } }, + { $type: 'mediaRating', output: { nsfwLevel: 'pg' } }, + ]; + await deliver('succeeded', steps); + + expect(mocks.processImageScanWorkflow).toHaveBeenCalledTimes(1); + expect(mocks.processImageScanWorkflow).toHaveBeenCalledWith(pipelineInput('succeeded', steps)); + expect(mocks.processImageScanningWorkflow).not.toHaveBeenCalled(); + }); + + it('rejects a workflow it cannot load or that carries no imageId', async () => { + mocks.getWorkflow.mockResolvedValueOnce({ data: undefined }); + const missing = await call({ workflowId: 'wf', status: 'succeeded' }); + expect(missing.status).toHaveBeenCalledWith(400); + expect(missing.send).toHaveBeenCalledWith({ error: 'could not find workflow: wf' }); + + mocks.getWorkflow.mockResolvedValueOnce({ data: { metadata: {}, steps: [] } }); + const noImage = await call({ workflowId: 'wf', status: 'succeeded' }); + expect(noImage.send).toHaveBeenCalledWith({ + error: 'missing workflow metadata.imageId - wf', + }); + }); + + it.each([ + ['imageScanning', [{ $type: 'imageScanning' }], 'image-scanning-result'], + ['legacy', [{ $type: 'wdTagging' }, { $type: 'mediaRating' }], 'image-scan-result'], + ])('logs a %s pipeline failure under its own name', async (_, steps, name) => { + const pipeline = + name === 'image-scanning-result' + ? mocks.processImageScanningWorkflow + : mocks.processImageScanWorkflow; + pipeline.mockRejectedValueOnce(new Error('boom')); + const res = await deliver('succeeded', steps); + + expect(res.status).toHaveBeenCalledWith(400); + expect(loggingMock.logToAxiom).toHaveBeenCalledWith( + expect.objectContaining({ name, type: 'error', message: 'boom' }) + ); + }); + + it('acknowledges a result for an image deleted since the scan was submitted', async () => { + mocks.processImageScanningWorkflow.mockRejectedValueOnce(new Error('image not found: 7')); + const res = await deliver('succeeded', [{ $type: 'imageScanning', output: {} }]); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ ok: true, skipped: 'deleted' }); + }); +}); diff --git a/src/__tests__/pages/api/webhooks/image-scan-result.test.ts b/src/__tests__/pages/api/webhooks/image-scan-result.test.ts index 59ce8f2dda..de247d4dc2 100644 --- a/src/__tests__/pages/api/webhooks/image-scan-result.test.ts +++ b/src/__tests__/pages/api/webhooks/image-scan-result.test.ts @@ -1,11 +1,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { NextApiRequest, NextApiResponse } from 'next'; -import handler from '~/pages/api/webhooks/image-scan-result'; import { processImageScanWorkflow } from '~/server/services/image-scan-result.service'; +import { processImageScanningWorkflow } from '~/server/services/image-scanning-result.service'; +import { clickhouse } from '~/server/clickhouse/client'; import { signalClient } from '~/utils/signal-client'; import type * as ClickhouseClient from '~/server/clickhouse/client'; import { TagSource, ImageIngestionStatus } from '~/shared/utils/prisma/enums'; -import { NsfwLevel } from '~/server/common/enums'; import type * as BlocklistService from '~/server/services/blocklist.service'; const { @@ -271,7 +270,6 @@ vi.mock('~/server/services/image.service', () => ({ getImagesModRules: vi.fn().mockResolvedValue([]), queueImageSearchIndexUpdate: vi.fn().mockResolvedValue(undefined), enqueueImageIngestion: vi.fn().mockResolvedValue(undefined), - imageScanTypes: [3, 9], // ImageScanType.WD14, ImageScanType.SpineRating })); // Render a Prisma tagged-template call the way the driver does: nested `Prisma.sql` fragments @@ -403,158 +401,6 @@ describe('image-scan-result webhook - pipeline tests', () => { }); }); - const runWebhook = (body: any) => { - const req = { - method: 'POST', - query: { token: 'mock-webhook-token' }, - headers: { host: 'localhost:3000' }, - body, - } as unknown as NextApiRequest; - - const res = { - status: vi.fn().mockReturnThis(), - json: vi.fn().mockImplementation((val) => res), - send: vi.fn().mockImplementation((val) => res), - } as unknown as NextApiResponse; - - return { promise: handler(req, res), res }; - }; - - it('should prevent tag-bleed between Clavata (ignored) and WD14 (not ignored) concurrently', async () => { - const reqA = runWebhook({ - id: 1, - status: 0, - source: TagSource.Clavata, - tags: [{ tag: 'hate symbols', confidence: 95 }], - }); - - const reqB = runWebhook({ - id: 2, - status: 0, - source: TagSource.WD14, - tags: [{ tag: 'hate symbols', confidence: 95 }], - }); - - await Promise.all([reqA.promise, reqB.promise]); - - expect(reqA.res.status).toHaveBeenCalledWith(200); - expect(reqB.res.status).toHaveBeenCalledWith(200); - - const dbUpdates = mockDbWrite.image.update.mock.calls; - const updateForImage2 = dbUpdates.find((call: any) => call[0].where.id === 2); - - expect(updateForImage2).toBeDefined(); - expect(updateForImage2[0].data.nsfwLevel).toBe(32); // Blocked - expect(updateForImage2[0].data.ingestion).toBe(ImageIngestionStatus.Scanned); - - const insertedTags = [ - ...mockInsertTagsOnImageNew.mock.calls.flatMap((call) => call[0]), - ...mockUpsertTagsOnImageNew.mock.calls.flatMap((call) => call[0]), - ]; - const tagForImage1 = insertedTags.find((t) => t.imageId === 1 && t.tagId === 100); - const tagForImage2 = insertedTags.find((t) => t.imageId === 2 && t.tagId === 100); - - expect(tagForImage1).toBeDefined(); - expect(tagForImage1.disabled).toBe(true); // Clavata ignores hate symbols - - expect(tagForImage2).toBeDefined(); - expect(tagForImage2.disabled).toBe(false); // WD14 does not ignore hate symbols - }); - - it('should set ingestion to NotFound when status is NotFound', async () => { - const req = runWebhook({ - id: 3, - status: 1, // NotFound - source: TagSource.WD14, - tags: [], - }); - - await req.promise; - - expect(req.res.status).toHaveBeenCalledWith(200); - expect(mockDbWrite.image.updateMany).toHaveBeenCalledWith({ - where: { id: 3, ingestion: { in: ['Pending', 'Error'] } }, - data: { ingestion: ImageIngestionStatus.NotFound }, - }); - }); - - it('should increment retryCount when status is Unscannable', async () => { - const req = runWebhook({ - id: 4, - status: 2, // Unscannable - source: TagSource.WD14, - tags: [], - }); - - await req.promise; - - expect(req.res.status).toHaveBeenCalledWith(200); - const queryCall = imageUpdates.find( - (update) => update.text.includes('retryCount') && update.params.includes(4) - ); - expect(queryCall).toBeDefined(); - }); - - it('should set needsReview: minor when minor tag is present and image is NSFW', async () => { - const req = runWebhook({ - id: 5, - status: 0, - source: TagSource.WD14, - tags: [ - { tag: 'teen', confidence: 95 }, - { tag: 'r', confidence: 95 }, - ], - }); - - await req.promise; - - expect(req.res.status).toHaveBeenCalledWith(200); - const dbUpdates = mockDbWrite.image.update.mock.calls; - const updateForImage5 = dbUpdates.find((call: any) => call[0].where.id === 5); - expect(updateForImage5).toBeDefined(); - expect(updateForImage5[0].data.needsReview).toBe('minor'); - }); - - it('should set needsReview: poi when POI tag is present', async () => { - const req = runWebhook({ - id: 6, - status: 0, - source: TagSource.WD14, - tags: [ - { tag: 'potential celebrity', confidence: 95 }, - { tag: 'pg', confidence: 95 }, - ], - }); - - await req.promise; - - expect(req.res.status).toHaveBeenCalledWith(200); - const dbUpdates = mockDbWrite.image.update.mock.calls; - const updateForImage6 = dbUpdates.find((call: any) => call[0].where.id === 6); - expect(updateForImage6).toBeDefined(); - expect(updateForImage6[0].data.needsReview).toBe('poi'); - }); - - it('should not update nsfwLevel if nsfwLevelLocked is true', async () => { - const req = runWebhook({ - id: 7, - status: 0, - source: TagSource.WD14, - tags: [ - { tag: 'some-tag', confidence: 95 }, - { tag: 'x', confidence: 95 }, - ], - }); - - await req.promise; - - expect(req.res.status).toHaveBeenCalledWith(200); - const dbUpdates = mockDbWrite.image.update.mock.calls; - const updateForImage7 = dbUpdates.find((call: any) => call[0].where.id === 7); - expect(updateForImage7).toBeDefined(); - expect(updateForImage7[0].data.nsfwLevel).toBeUndefined(); // locked, so undefined (not updated) - }); - it('creates a never-before-seen tag with the columns the Tag table requires', async () => { const passthrough = mockDbWrite.$queryRaw.getMockImplementation(); let tagInsert: { text: string; params: any[] } | undefined; @@ -746,208 +592,202 @@ describe('image-scan-result webhook - pipeline tests', () => { expect(errorFlips).toHaveLength(0); }); - describe('webhook body strings never reach SQL text', () => { - const INJECTED = `1 OR 1=1) < 5 AND disabled = false -- `; - - it('rejects a non-numeric hash before it can reach the ClickHouse query', async () => { - envOverrides.BLOCKED_IMAGE_HASH_CHECK = true; - - const req = runWebhook({ - id: 8, - status: 0, - source: TagSource.ImageHash, - hash: INJECTED, - }); - await req.promise; - - expect(mockClickhouseQuery).not.toHaveBeenCalled(); - expect(req.res.status).toHaveBeenCalledWith(400); + // The imageScanning pipeline through the real shared stages; only the database, ClickHouse + // and signals are faked. Scan outputs follow the shape the orchestrator returned on 2026-09-17. + describe('imageScanning workflows through the shared pipeline', () => { + const scanOutput = (overrides: Record = {}) => ({ + nsfwLevel: 'x', + score: 0.91, + topK: [{ label: 'X', score: 0.91 }], + aiRecognition: { label: 'AI', score: 0.86, topK: [] }, + animeRecognition: { label: 'anime', score: 0.9, scores: {} }, + humanRecognition: { + status: 'ok', + ran: true, + label: 'human', + score: 0.7, + humanScore: 0.7, + noHumanScore: 0.3, + scores: {}, + evidence: [], + }, + tagging: { + status: 'ran', + ran: true, + threshold: 0.55, + tagCount: 3, + totalAboveThreshold: 3, + truncated: false, + tags: [ + { tag: 'teen', category: 'general', score: 0.9 }, + { tag: 'hate symbols', category: 'copyright', score: 0.8 }, + { tag: 'some-tag', category: 'meta', score: 0.8 }, + ], + }, + jointAgeClassification: { + status: 'ran', + ran: true, + detections: [{ ageBand: '21-24', under18Probability: 0.06, isMinor: false }], + minorDetected: false, + }, + csam: false, + ...overrides, }); - - it('logs a match against the parsed hash, not the string the scanner sent', async () => { - envOverrides.BLOCKED_IMAGE_HASH_CHECK = true; - mockClickhouseQuery.mockResolvedValue([{ count: 1 }]); - - const req = runWebhook({ id: 16, status: 0, source: TagSource.ImageHash, hash: ' 42 ' }); - await req.promise; - - const match = mockLogToAxiom.mock.calls.find( - (call) => call[0]?.message === 'Image pHash matched a blocked image' - ); - expect(match).toBeDefined(); - expect(match![0].pHash).toBe('42'); + const scanStep = (output: unknown, status = 'succeeded') => ({ + $type: 'imageScanning', + name: 'scan', + status, + output, }); - - it('passes a valid hash to ClickHouse as digits only', async () => { - envOverrides.BLOCKED_IMAGE_HASH_CHECK = true; - - const req = runWebhook({ - id: 9, - status: 0, - source: TagSource.ImageHash, - hash: '-1234567890123456789', - }); - await req.promise; - - expect(mockClickhouseQuery).toHaveBeenCalled(); - const [strings, ...values] = mockClickhouseQuery.mock.calls[0]; - expect(strings.join('?')).toContain('bitXor(hash, ?)'); - expect(values).toEqual([-1234567890123456789n]); - }); - - it('sends the scanner-supplied model id as a parameter, not as SQL text', async () => { - const req = runWebhook({ - id: 10, - status: 0, - source: TagSource.WD14, - tags: [], - context: { movie_rating: 'PG', movie_rating_model_id: `x'; DROP TABLE "Image"; --` }, - }); - await req.promise; - - const update = imageUpdates.find((u) => u.text.includes('"aiModel"')); - expect(update).toBeDefined(); - expect(update!.text).not.toContain('DROP TABLE'); - expect(update!.params).toContain(`x'; DROP TABLE "Image"; --`); - }); - }); - - describe('perceptual hash handling', () => { - it('records the scan without a lookup when the hash is blank', async () => { - envOverrides.BLOCKED_IMAGE_HASH_CHECK = true; - - const req = runWebhook({ id: 11, status: 0, source: TagSource.ImageHash, hash: ' ' }); - await req.promise; - - expect(mockClickhouseQuery).not.toHaveBeenCalled(); - expect(req.res.status).toHaveBeenCalledWith(200); - const update = imageUpdates.find((u) => u.text.includes('jsonb_build_object')); - expect(update).toBeDefined(); - expect(update!.text).not.toContain('"pHash"'); - expect( - mockLogToAxiom.mock.calls.some( - (call) => call[0]?.message === 'blank hash from ImageHash scan' - ) - ).toBe(true); - }); - - it('stores a zero hash but does not look it up', async () => { - envOverrides.BLOCKED_IMAGE_HASH_CHECK = true; - - const req = runWebhook({ id: 15, status: 0, source: TagSource.ImageHash, hash: '0' }); - await req.promise; - - const update = imageUpdates.find((u) => u.text.includes('"pHash"')); - expect(update).toBeDefined(); - expect(update!.params).toContain(0n); - expect(mockClickhouseQuery).not.toHaveBeenCalled(); - }); - - it('completes the scan when the blocklist lookup fails', async () => { - envOverrides.BLOCKED_IMAGE_HASH_CHECK = true; - mockClickhouseQuery.mockRejectedValue(new Error('socket hang up')); - - const req = runWebhook({ id: 14, status: 0, source: TagSource.ImageHash, hash: '42' }); - await req.promise; - - expect(mockClickhouseQuery).toHaveBeenCalled(); - expect(req.res.status).toHaveBeenCalledWith(200); - const update = imageUpdates.find((u) => u.params.includes(42n)); - expect(update).toBeDefined(); - }); - - it('rejects a hash wider than Int64 rather than letting ClickHouse reject it', async () => { - envOverrides.BLOCKED_IMAGE_HASH_CHECK = true; - - const req = runWebhook({ - id: 12, - status: 0, - source: TagSource.ImageHash, - hash: '99999999999999999999', - }); - await req.promise; - - expect(mockClickhouseQuery).not.toHaveBeenCalled(); - expect(req.res.status).toHaveBeenCalledWith(400); - }); - - it('binds a hash as a bigint parameter, not as SQL text', async () => { - const req = runWebhook({ - id: 13, - status: 0, - source: TagSource.ImageHash, - hash: '4611686018427387904', - }); - await req.promise; - - const update = imageUpdates.find((u) => u.text.includes('"pHash"')); - expect(update).toBeDefined(); - expect(update!.params).toContain(4611686018427387904n); - expect(update!.text).not.toContain('4611686018427387904'); - }); - }); - - describe('moderator benign phrases reach the POI tagger', () => { - // The file's own `beforeEach` is `vi.clearAllMocks()`, which clears calls but NOT - // implementations β€” so the one a test below installs would leak into anything appended - // after this describe. Harmless while this is the last block, which is exactly the kind - // of "harmless" that stops being true without anyone noticing. - beforeEach(() => { - mockStripBenignPhrases.mockImplementation(async (text?: string) => text ?? ''); - }); - - const seedImageWithPrompt = (id: number, prompt: string) => { - imageDbState.set(id, { - id, - createdAt: new Date(), - scannedAt: null, - type: 'image', - userId: 1, - meta: { prompt }, - metadata: {}, - postId: null, - nsfwLevelLocked: false, - nsfwLevel: null, - scanJobs: { scans: {} }, - ingestion: ImageIngestionStatus.Pending, - }); + const hashStep = { + $type: 'mediaHash', + name: 'hash', + status: 'succeeded', + output: { hashes: { perceptual: '6F51B11C49611E0E' } }, }; + const deliver = (imageId: number, steps: unknown[], status = 'succeeded') => + processImageScanningWorkflow({ workflowId: `wf-${imageId}`, status, steps, imageId }); - const requestedTagNames = () => - mockDbWrite.tag.findMany.mock.calls.flatMap((call: any) => call[0]?.where?.name?.in ?? []); + const sqlOf = (call: any[]) => + renderSql(Array.isArray(call[0]) ? call[0] : call[0].strings, call.slice(1)); + // The verdict row written by resolveScanOutcome. + const verdictFor = (imageId: number) => { + const update = mockDbWrite.$executeRaw.mock.calls + .map(sqlOf) + .find( + (sql: any) => + sql.text.includes('UPDATE "Image"') && + sql.text.includes('"needsReview"') && + sql.params.at(-1) === imageId + ); + if (!update) return undefined; + const param = (column: string) => { + const match = update.text.match(new RegExp(`"${column}" = \\$(\\d+)`)); + return match ? update.params[Number(match[1]) - 1] : undefined; + }; + return { + ingestion: param('ingestion'), + nsfwLevel: param('nsfwLevel'), + needsReview: param('needsReview'), + minor: param('minor'), + pHash: param('pHash'), + }; + }; + const tagsWritten = (imageId: number) => + mockInsertTagsOnImageNew.mock.calls + .flatMap((call) => call[0]) + .filter((tag: any) => tag.imageId === imageId); + const errorFlipsFor = (imageId: number) => + mockDbWrite.$queryRaw.mock.calls + .map(sqlOf) + .filter((sql: any) => sql.text.includes("'{retryCount}'") && sql.params.includes(imageId)); + const auditRows = () => + vi + .mocked(clickhouse!.insert) + .mock.calls.filter((call: any) => call[0].table === 'scanner_label_results') + .flatMap((call: any) => call[0].values); - it('CONTROL: a POI name that is not whitelisted is still tagged', async () => { - seedImageWithPrompt(30, 'emma stone'); - - const req = runWebhook({ id: 30, status: 0, source: TagSource.WD14, tags: [] }); - await req.promise; - - // Deliberately asserts only the tag, with the strip passing the text through: it - // stays green with the fix reverted, so a failure below is the strip and not a - // broken fixture. - expect(requestedTagNames()).toContain('emma stone'); + beforeEach(() => { + // An earlier test leaves $executeRaw throwing, which would skip the verdict write. + mockDbWrite.$executeRaw.mockResolvedValue(0); + vi.mocked(clickhouse!.insert).mockClear(); }); - it('a whitelisted phrase is stripped before the POI check, so no POI tag is written', async () => { - mockStripBenignPhrases.mockImplementation(async (text?: string) => - (text ?? '').replace('emma stone', '') - ); - // Two real POI names, only one whitelisted. `tom hanks` survives the strip and must - // still be tagged, which proves this run reached the tag write at all β€” otherwise an - // early throw would satisfy the negative assertion below with an empty array. - seedImageWithPrompt(31, 'emma stone and tom hanks'); + it('writes general tags, the rating and a Scanned verdict for an image', async () => { + await deliver(40, [scanStep(scanOutput()), hashStep]); - const req = runWebhook({ id: 31, status: 0, source: TagSource.WD14, tags: [] }); - await req.promise; - - // Pins WHICH list is consulted. Pointing the strip at ProfanityBenignWord instead - // leaves the whole fix inert in production and every other assertion here green. - expect(mockStripBenignPhrases).toHaveBeenCalledWith( - 'emma stone and tom hanks', - 'PromptBenignPhrase' + const written = tagsWritten(40); + expect(written).toContainEqual( + expect.objectContaining({ tagId: 200, source: TagSource.WD14, confidence: 90 }) ); - expect(requestedTagNames()).not.toContain('emma stone'); - expect(requestedTagNames()).toContain('tom hanks'); + expect(written).toContainEqual( + expect.objectContaining({ tagId: 1004, source: TagSource.SpineRating }) + ); + // Other tagger categories are not ingested, even when the tag exists. + expect(written.some((tag: any) => tag.tagId === 100 || tag.tagId === 400)).toBe(false); + + expect(verdictFor(40)).toMatchObject({ + ingestion: ImageIngestionStatus.Scanned, + nsfwLevel: 8, + pHash: BigInt('0x6F51B11C49611E0E'), + }); + expect(errorFlipsFor(40)).toHaveLength(0); + expect(vi.mocked(signalClient.send)).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ imageId: 40, ingestion: ImageIngestionStatus.Scanned }), + }) + ); + }); + + it('queues an NSFW image with a minor tag for review', async () => { + await deliver(41, [scanStep(scanOutput())]); + expect(verdictFor(41)).toMatchObject({ needsReview: 'minor', minor: true }); + }); + + it('leaves a moderator-locked NSFW level alone', async () => { + await deliver(7, [scanStep(scanOutput())]); + expect(verdictFor(7)).toMatchObject({ + ingestion: ImageIngestionStatus.Scanned, + nsfwLevel: 1, + }); + }); + + it('writes version 2 scanner-audit rows, csam included', async () => { + await deliver(42, [scanStep(scanOutput({ csam: true })), hashStep]); + + const rows = auditRows(); + expect(rows.map((row: any) => [row.label, row.labelValue, row.triggered])).toEqual([ + ['x', 'nsfw_level', 1], + ['csam', '', 1], + ['minor', '21-24', 0], + ['ai', 'ai_recognition', 1], + ['anime', 'anime_recognition', 1], + ]); + expect(rows.every((row: any) => row.version === '2' && row.entityIds[0] === '42')).toBe(true); + }); + + it('rates a video by its riskiest frame', async () => { + await deliver(43, [ + { $type: 'videoFrameExtraction', name: 'videoFrames', status: 'succeeded', output: {} }, + { + $type: 'repeat', + status: 'succeeded', + input: { template: { $type: 'imageScanning' } }, + output: { + steps: [ + scanStep(scanOutput({ nsfwLevel: 'pg' })), + scanStep(scanOutput({ nsfwLevel: 'x' })), + scanStep(scanOutput({ nsfwLevel: 'pg13' })), + ], + }, + }, + ]); + + expect(tagsWritten(43)).toContainEqual( + expect.objectContaining({ tagId: 1004, source: TagSource.SpineRating }) + ); + expect(verdictFor(43)).toMatchObject({ + ingestion: ImageIngestionStatus.Scanned, + nsfwLevel: 8, + }); + }); + + it.each([ + ['a failed workflow', [scanStep(undefined, 'failed')], 'failed', 'workflow-failed'], + [ + 'a scan whose tagging did not run', + [scanStep(scanOutput({ tagging: { status: 'skipped', ran: false, tags: [] } }))], + 'succeeded', + 'unusable-result', + ], + ])('marks %s as one Error without a verdict', async (_, steps, status, failureType) => { + await deliver(44, steps, status); + + const flips = errorFlipsFor(44); + expect(flips).toHaveLength(1); + expect(flips[0].params.join(' ')).toContain(failureType); + expect(verdictFor(44)).toBeUndefined(); + expect(tagsWritten(44)).toHaveLength(0); }); }); }); diff --git a/src/env/server-schema.ts b/src/env/server-schema.ts index ffd8ca8734..73de6a89c4 100644 --- a/src/env/server-schema.ts +++ b/src/env/server-schema.ts @@ -389,10 +389,8 @@ export const serverSchema = z UNAUTHENTICATED_DOWNLOAD: zc.booleanString, UNAUTHENTICATED_LIST_NSFW: zc.booleanString, LOGGING: commaDelimitedStringArray(), - IMAGE_SCANNING_ENDPOINT: isProd ? z.string() : z.string().optional(), IMAGE_SCANNING_CALLBACK: z.string().optional(), TEXT_MODERATION_CALLBACK: z.string().optional(), - IMAGE_SCANNING_MODEL: z.string().optional(), IMAGE_SCANNING_RETRY_DELAY: z.coerce.number().default(5), // Age-out threshold (minutes) for never-returning image scans. A scan verdict // arrives via the fire-and-forget /image-scan-result webhook; a fraction never @@ -419,7 +417,6 @@ export const serverSchema = z // newest-first (starving the oldest backlog) β€” a bad hand-tune must fail // loudly at boot instead. IMAGE_SCANNING_MAX_PER_RUN: z.coerce.number().int().positive().default(1000), - IMAGE_SCANNER_NEW: zc.booleanString.default(false), DELIVERY_WORKER_ENDPOINT: z.string().optional(), DELIVERY_WORKER_TOKEN: z.string().optional(), STORAGE_RESOLVER_ENDPOINT: z.string().optional(), // URL for storage-resolver microservice diff --git a/src/pages/api/internal/add-missing-phash.ts b/src/pages/api/internal/add-missing-phash.ts deleted file mode 100644 index b3d1790761..0000000000 --- a/src/pages/api/internal/add-missing-phash.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { chunk } from 'lodash-es'; -import type { NextApiRequest, NextApiResponse } from 'next'; -import * as z from 'zod'; -import { ImageScanType } from '~/server/common/enums'; -import { dbRead } from '~/server/db/client'; -import { dataProcessor } from '~/server/db/db-helpers'; -import type { IngestImageInput } from '~/server/schema/image.schema'; -import { ingestImageBulk } from '~/server/services/image.service'; -import { ModEndpoint } from '~/server/utils/endpoint-helpers'; - -const schema = z.object({ - start: z.coerce.number().optional(), - end: z.coerce.number().optional(), -}); - -export default ModEndpoint(async function (req: NextApiRequest, res: NextApiResponse) { - const input = schema.parse(req.query); - const start = Date.now(); - await dataProcessor({ - params: { batchSize: 100000, concurrency: 10, start: 0 }, - runContext: { - on: (event: 'close', listener: () => void) => { - // noop - }, - }, - rangeFetcher: async (ctx) => { - let [{ start, end }] = await dbRead.$queryRaw<{ start: number; end: number }[]>` - WITH dates AS ( - SELECT - MIN("createdAt") as start, - MAX("createdAt") as end - FROM "Image" - ) - SELECT MIN(id) as start, MAX(id) as end - FROM "Image" i - JOIN dates d ON d.start = i."createdAt" OR d.end = i."createdAt"; - `; - if (input.start) start = input.start; - if (input.end) end = input.end; - - return { start, end }; - }, - processor: async ({ start, end }) => { - const consoleFetchKey = `Fetch: ${start} - ${end}`; - console.log(consoleFetchKey); - console.time(consoleFetchKey); - const records = await dbRead.$queryRaw` - SELECT - "id", - "url", - "type", - "height", - "width", - meta->>'prompt' as prompt - FROM "Image" i - WHERE i.id BETWEEN ${start} AND ${end} - AND i."pHash" IS NULL - `; - console.timeEnd(consoleFetchKey); - - if (records.length === 0) return; - - const consolePushKey = `Push: ${start} - ${end}: ${records.length}`; - console.log(consolePushKey); - console.time(consolePushKey); - for (const batch of chunk(records, 1000)) { - await ingestImageBulk({ - images: batch, - scans: [ImageScanType.Hash], - lowPriority: true, - }); - } - console.timeEnd(consolePushKey); - }, - }); - - return res.status(200).json({ success: true, duration: Date.now() - start }); -}); diff --git a/src/pages/api/mod/scan-images.ts b/src/pages/api/mod/scan-images.ts deleted file mode 100644 index c38f3f9a0c..0000000000 --- a/src/pages/api/mod/scan-images.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from 'next'; -import { dbWrite } from '~/server/db/client'; -import * as z from 'zod'; -import { ModEndpoint } from '~/server/utils/endpoint-helpers'; -import type { Prisma } from '@prisma/client'; -import { env } from '~/env/server'; -import { chunk } from 'lodash-es'; -import { getEdgeUrl } from '~/client-utils/edge-url'; - -const stringToNumberArraySchema = z - .string() - .transform((s) => s.split(',').map(Number)) - .optional(); -const importSchema = z.object({ - imageCount: z.preprocess((x) => (x ? parseInt(String(x)) : undefined), z.number()).optional(), - imageIds: stringToNumberArraySchema, - wait: z.preprocess((val) => val === true || val === 'true', z.boolean()).optional(), -}); - -export default ModEndpoint( - async function scanImages(req: NextApiRequest, res: NextApiResponse) { - if (!env.IMAGE_SCANNING_ENDPOINT) - return res.status(400).json({ error: 'Image scanning is not enabled' }); - - const { imageCount, imageIds, wait } = importSchema.parse(req.query); - - const where: Prisma.Enumerable = {}; - if (!!imageIds?.length) where.id = { in: imageIds }; - else if (!!imageCount) { - where.scanRequestedAt = null; - where.scannedAt = null; - } else { - return res.status(400).json({ - error: 'Must provide at least one of imageCount or imageIds', - }); - } - - const images = await dbWrite.image.findMany({ - where, - take: imageCount, - select: { url: true, id: true, width: true, name: true, mimeType: true }, - }); - - if (!wait) res.status(200).json({ images: images.length }); - - const batchSize = 100; - const batches = chunk(images, batchSize); - let i = 0; - for (const batch of batches) { - console.log( - `Sending batch ${i} to ${Math.min(i + batchSize, images.length)} of ${images.length} images` - ); - const queued: number[] = []; - await Promise.all( - batch.map(async (image) => { - const width = Math.min(450, image.width ?? 450); - const anim = - image.name?.endsWith('.gif') || image.mimeType == 'image/gif' ? false : undefined; - const gamma = anim === false ? 0.99 : undefined; - const url = getEdgeUrl(image.url, { width, anim, gamma }); - - try { - await fetch(env.IMAGE_SCANNING_ENDPOINT as string, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url, imageId: image.id }), - }); - queued.push(image.id); - } catch (e: any) { - console.error('Failed to send image for scan', e.message); - } - }) - ); - - if (!!queued.length) { - await dbWrite.image.updateMany({ - where: { id: { in: queued } }, - data: { scanRequestedAt: new Date() }, - }); - } - - i += batchSize; - } - console.log('Done sending images for scan!'); - - if (wait) res.status(200).json({ images: images.length }); - }, - ['GET'] -); diff --git a/src/pages/api/webhooks/image-scan-result.ts b/src/pages/api/webhooks/image-scan-result.ts index 152b08eb21..97d69475b1 100644 --- a/src/pages/api/webhooks/image-scan-result.ts +++ b/src/pages/api/webhooks/image-scan-result.ts @@ -1,1283 +1,93 @@ -import { isDev } from '~/env/other'; -import { Prisma } from '@prisma/client'; -import { uniqBy } from 'lodash-es'; -import * as z from 'zod'; -import { env } from '~/env/server'; -import { styleTags, tagsNeedingReview, tagsToIgnore } from '~/libs/tags'; -import { clickhouse } from '~/server/clickhouse/client'; -import { - BlockedReason, - BlocklistType, - ImageScanType, - NotificationCategory, - NsfwLevel, - SearchIndexUpdateQueueAction, - SignalMessages, -} from '~/server/common/enums'; -import { stripBenignPhrases } from '~/server/services/blocklist.service'; -import { dbRead, dbWrite } from '~/server/db/client'; -import { getExplainSql } from '~/server/db/db-helpers'; -import { logToAxiom } from '~/server/logging/client'; -import { tagIdsForImagesCache, userImageVideoCountCaches } from '~/server/redis/caches'; -import type { ImageMetadata, VideoMetadata } from '~/server/schema/media.schema'; -import { addImageToQueue } from '~/server/services/games/new-order.service'; -import { createImageTagsForReview } from '~/server/services/image-review.service'; -import { - getImagesModRules, - queueImageSearchIndexUpdate, - imageScanTypes, -} from '~/server/services/image.service'; -import { createNotification } from '~/server/services/notification.service'; -import { updateModel3DNsfwLevelForThumbnailImage } from '~/server/services/nsfwLevels.service'; -import { updatePostNsfwLevel } from '~/server/services/post.service'; -import { getTagRules } from '~/server/services/system-cache'; -import { - insertTagsOnImageNew, - upsertTagsOnImageNew, -} from '~/server/services/tagsOnImageNew.service'; -import { deleteUserProfilePictureCache } from '~/server/services/user.service'; -import { imageScanWebhookCounter } from '~/server/prom/client'; -import { WebhookEndpoint } from '~/server/utils/endpoint-helpers'; -import { evaluateRules } from '~/server/utils/mod-rules'; -import { getComputedTags } from '~/server/utils/tag-rules'; -import { sfwBrowsingLevelsFlag } from '~/shared/constants/browsingLevel.constants'; -import { - ImageIngestionStatus, - MediaType, - ModerationRuleAction, - NewOrderRankType, - TagSource, - TagTarget, - TagType, -} from '~/shared/utils/prisma/enums'; -import { decreaseDate } from '~/utils/date-helpers'; -import { - auditMetaData, - getTagsFromPrompt, - includesInappropriate, - includesPoi, -} from '~/utils/metadata/audit'; -import poiWords from '~/utils/metadata/lists/words-poi.json'; -import { normalizeText } from '~/utils/normalize-text'; -import { removeEmpty } from '~/utils/object-helpers'; -import { signalClient } from '~/utils/signal-client'; -import { isDefined } from '~/utils/type-guards'; -import { processImageScanResult } from '~/server/services/image-scan-result.service'; -import { removeImageScanJobQueue } from '~/server/services/job-queue.service'; -import { fanOutArticleImageUpdates } from '~/server/utils/webhook-debounce'; -import { getFeatureFlagsLazy } from '~/server/services/feature-flags.service'; +import { getWorkflow, type WorkflowEvent } from '@civitai/client'; import type { NextApiRequest } from 'next'; - -// const REQUIRED_SCANS = 2; - -enum Status { - Success = 0, - NotFound = 1, // image not found at url - Unscannable = 2, -} - -const pendingStates: ImageIngestionStatus[] = [ - ImageIngestionStatus.Pending, - ImageIngestionStatus.Error, -]; - -type IncomingTag = z.infer; -const tagSchema = z.object({ - tag: z.string().transform((x) => x.toLowerCase().trim()), - id: z.number().optional(), - confidence: z.number(), -}); -type BodyProps = z.infer; -const schema = z.object({ - id: z.number(), - tags: tagSchema - .array() - .nullish() - .transform((tags) => (tags ? tags.map((x) => ({ ...x, tag: x.tag.toLowerCase() })) : null)), - hash: z.string().nullish(), - vectors: z.array(z.number().array()).nullish(), - status: z.enum(Status), - source: z.enum(TagSource), - context: z - .object({ - movie_rating: z.string().optional(), - movie_rating_model_id: z.string().optional(), - // hasMinor: z.boolean().optional(), - blockedReason: z.string().nullish(), - }) - .nullish(), -}); - -function shouldIgnore(tag: string, source: TagSource) { - return tagsToIgnore[source]?.includes(tag) ?? false; -} - -const KONO_NSFW_SAMPLING_RATE = 0.3; // 30% +import { logToAxiom } from '~/server/logging/client'; +import { imageScanWebhookCounter } from '~/server/prom/client'; +import { getFeatureFlagsLazy } from '~/server/services/feature-flags.service'; +import { + captureJobFailureReason, + LEGACY_SCAN_LOG, + type OrchestratorJobEvent, +} from '~/server/services/image-scan-pipeline'; +import { + processImageScanWorkflow, + type ScanResultStep, +} from '~/server/services/image-scan-result.service'; +import { + IMAGE_SCANNING_LOG, + isImageScanningWorkflow, + processImageScanningWorkflow, +} from '~/server/services/image-scanning-result.service'; +import { internalOrchestratorClient } from '~/server/services/orchestrator/client'; +import { WebhookEndpoint } from '~/server/utils/endpoint-helpers'; export default WebhookEndpoint(async (req, res) => { - if (req.method === 'GET' && req.query.imageId) { - const imageId = Number(req.query.imageId); - const image = await getImage(imageId); - const result = await auditImageScanResults({ image }); - if (req.query.rescan) await updateImage(image, result, req); - return res.status(200).json({ audit: result, image }); - } if (req.method !== 'POST') return res.status(405).json({ ok: false, error: 'Method Not Allowed' }); - if ('workflowId' in req.body) { - try { - await processImageScanResult(req); - } catch (e: any) { - // Image was deleted between scan submit and callback β€” nothing to update. - // ACK with 200 so the orchestrator drops the workflow instead of retrying - // a result for a row that no longer exists. - if (e instanceof Error && e.message.startsWith('image not found')) { - imageScanWebhookCounter.inc({ result: 'deleted_skip' }); - return res.status(200).json({ ok: true, skipped: 'deleted' }); - } - if (e instanceof Error) { - await logToAxiom({ - name: 'image-scan-result', - type: 'error', - message: e.message, - stack: e?.stack, - cause: e?.cause, - }); - } - imageScanWebhookCounter.inc({ result: 'error' }); - return res.status(400).send({ error: e.message }); - } - imageScanWebhookCounter.inc({ result: 'success' }); - return res.status(200).json({ ok: true }); - } - - const bodyResults = schema.safeParse(req.body); - if (!bodyResults.success) - return res.status(400).json({ - ok: false, - error: bodyResults.error, - }); - - const data = bodyResults.data; - - let webhookResult: 'success' | 'not_found' | 'unscannable'; + let log = LEGACY_SCAN_LOG; try { - switch (bodyResults.data.status) { - case Status.NotFound: - await dbWrite.image.updateMany({ - where: { id: data.id, ingestion: { in: pendingStates } }, - data: { ingestion: ImageIngestionStatus.NotFound }, - }); - webhookResult = 'not_found'; - break; - case Status.Unscannable: - await updateImageScanJobs({ - id: data.id, - ingestion: ImageIngestionStatus.Error, - incrementRetryCount: true, - whereIngestionIn: pendingStates, - }); - webhookResult = 'unscannable'; - logToAxiom( - { - name: 'image-scan-result', - type: 'warning', - message: 'legacy scanner returned Unscannable', - source: 'webhook-legacy', - failureType: 'unscannable', - imageId: data.id, - }, - 'webhooks' - ).catch(() => null); - break; - case Status.Success: - await handleSuccess(data, req); - webhookResult = 'success'; - break; - default: { - await logScanResultError({ id: data.id, message: 'unhandled data type' }); - throw new Error('unhandled data type'); - } - } - - const featureFlags = getFeatureFlagsLazy({ req }); - if (featureFlags.articleImageScanning) { - await fanOutArticleImageUpdates(data.id).catch(async (error) => { - await logToAxiom({ - name: 'image-scan-result', - type: 'error', - message: `fanOutArticleImageUpdates failed: ${ - error instanceof Error ? error.message : 'Unknown error' - }`, - imageId: data.id, - stack: error instanceof Error ? error.stack : undefined, - }).catch(() => null); + const scan = await loadScanEvent(req); + if (scan?.imageScanning) { + log = IMAGE_SCANNING_LOG; + await processImageScanningWorkflow(scan.input); + } else if (scan) { + await processImageScanWorkflow({ + ...scan.input, + steps: scan.input.steps as ScanResultStep[], }); } - - imageScanWebhookCounter.inc({ result: webhookResult }); - return res.status(200).json({ ok: true }); - } catch (e: any) { - // Image was deleted between scan submit and callback β€” there's nothing to - // update. ACK with 200 so the scanner drops the job instead of re-delivering - // the result for a row that no longer exists. - if (e.message === 'Image not found') { + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + // Deleted between submit and callback: ACK so the orchestrator stops retrying. + if (error.message.startsWith('image not found')) { imageScanWebhookCounter.inc({ result: 'deleted_skip' }); return res.status(200).json({ ok: true, skipped: 'deleted' }); } + await logToAxiom({ + name: log.name, + type: 'error', + message: error.message, + stack: error.stack, + cause: error.cause, + }); imageScanWebhookCounter.inc({ result: 'error' }); - return res.status(400).send({ error: e.message }); + return res.status(400).send({ error: error.message }); } + + imageScanWebhookCounter.inc({ result: 'success' }); + return res.status(200).json({ ok: true }); }); -type Tag = { tag: string; confidence: number; id?: number; source?: TagSource }; +// Route on the workflow's own steps, never the flag: both shapes are in flight across a flip. +async function loadScanEvent(req: NextApiRequest) { + const event: WorkflowEvent = req.body; -// `BigInt(' ')` is 0n, which a blank hash must not become: it is a legitimate hash value. -function parsePerceptualHash(hash: string) { - const invalid = new Error('invalid hash from ImageHash scan'); - if (!hash.trim()) return undefined; - - let parsed: bigint; - try { - parsed = BigInt(hash); - } catch { - throw invalid; + // Job events carry a top-level `jobId` and only a failure reason, which the terminal + // workflow event reads back to classify the failure. + const jobEvent = event as OrchestratorJobEvent; + if (typeof jobEvent.jobId === 'string') { + await captureJobFailureReason(jobEvent); + return null; } - if (BigInt.asIntN(64, parsed) !== parsed) throw invalid; - return parsed; -} - -// @see https://stackoverflow.com/questions/14925151/hamming-distance-optimization-for-mysql-or-postgresql -// 1-10: The images are visually almost identical -// 11-20: The images are visually similar -// 21-30: The images are visually somewhat similar -// -// `$query` interpolates into the SQL text rather than binding parameters, so this must -// only ever be handed a bigint β€” never the raw string off the webhook body. -async function isBlocked(hash: bigint) { - if (!env.BLOCKED_IMAGE_HASH_CHECK || !clickhouse) return false; - - const rows = await clickhouse.$query<{ count: number }>` - SELECT cast(count() as int) as count - FROM blocked_images - WHERE bitCount(bitXor(hash, ${hash})) < 5 AND disabled = false - `; - - return (rows?.[0]?.count ?? 0) > 0; -} - -async function handleSuccess(args: BodyProps, req: NextApiRequest) { - const { id } = args; - try { - const scanned = await processScanResult(args); - if (!scanned) return; - - // all scans have been processed - const image = await getImage(id); - if (image.ingestion === ImageIngestionStatus.Blocked) return; - - const result = await auditImageScanResults({ image }); - - await updateImage(image, result, req); - } catch (e: any) { - await logScanResultError({ id, message: e.message, error: e }); - throw new Error(e.message); - } -} - -async function updateImage( - image: GetImageReturn, - { data, reviewKey, tagsForReview = [], flags }: AuditImageScanResultsReturn, - req: NextApiRequest -) { - const { id } = image; - try { - await dbWrite.image.update({ where: { id }, data }); - - // Terminal outcome β€” drop the ImageScan JobQueue row so completed scans don't - // linger as stale entries the ingest-images cron has to prune. Non-terminal - // states (e.g. Error) stay queued for retry. - if (data.ingestion === 'Scanned' || data.ingestion === 'Blocked') { - await removeImageScanJobQueue([id]); - } - - if (data.ingestion === 'Scanned') { - if (reviewKey) { - await Promise.all([ - // createImageForReview({ imageId: id, reason: reviewKey }), - createImageTagsForReview({ imageId: id, tagIds: tagsForReview.map((x) => x.id) }), - ]); - } - - await userImageVideoCountCaches.bust(image.userId); - await tagIdsForImagesCache.refresh(id); - - const isProfilePicture = image.metadata?.profilePicture === true; - if (isProfilePicture) { - await deleteUserProfilePictureCache(image.userId); - } - - // await dbWrite.$executeRaw`SELECT update_nsfw_level_new(${id}::int);`; - if (image.postId) await updatePostNsfwLevel(image.postId); - await updateModel3DNsfwLevelForThumbnailImage({ imageId: id, postId: image.postId }); - - await queueImageSearchIndexUpdate({ ids: [id], action: SearchIndexUpdateQueueAction.Update }); - - // - this is still active - if (image.type === MediaType.image) { - // #region [NewOrder] - // New Order Queue Management. Only added when scan is succesful. - const queueDetails: { priority: 1 | 2 | 3; rankType: NewOrderRankType } = { - priority: 1, - rankType: NewOrderRankType.Knight, - }; - - // Sampling logic: Only include a fraction of NSFW images to reduce queue congestion - let shouldAddToQueue = true; - if (flags.nsfw) { - queueDetails.priority = 2; - shouldAddToQueue = Math.random() < KONO_NSFW_SAMPLING_RATE; - } - - if (reviewKey) { - data.needsReview = reviewKey; - // queueDetails.rankType = NewOrderRankType.Templar; - - // if (reviewKey === 'minor') queueDetails.priority = 1; - // if (reviewKey === 'poi') queueDetails.priority = 2; - - // never add images needing review regardless of sampling - shouldAddToQueue = false; - } - - if (shouldAddToQueue) { - // TODO.newOrder: Priority 1 for knights is not being used for the most part. We might wanna change that based off of tags or smt. - await addImageToQueue({ - imageIds: id, - rankType: queueDetails.rankType, - priority: queueDetails.priority, - }); - } - // #endregion - } - } else if (data.ingestion === 'Blocked') { - await updateModel3DNsfwLevelForThumbnailImage({ imageId: id, postId: image.postId }); - await queueImageSearchIndexUpdate({ ids: [id], action: SearchIndexUpdateQueueAction.Delete }); - } - - if (data.ingestion && data.ingestion !== 'Blocked') { - // The ingestion is already committed β€” a signals brownout must not 400 the webhook. - await signalClient - .send({ - target: SignalMessages.ImageIngestionStatus, - data: { imageId: image.id, ingestion: data.ingestion, blockedFor: data.blockedFor }, - userId: image.userId, - }) - .catch((error) => - logToAxiom( - { - name: 'image-scan-result', - type: 'warning', - message: `signal send failed: ${ - error instanceof Error ? error.message : 'Unknown error' - }`, - imageId: image.id, - source: 'webhook-legacy', - }, - 'webhooks' - ).catch(() => null) - ); - } - } catch (e) { - if (isDev) console.log({ error: e }); - throw e; - } -} - -async function logScanResultError({ - id, - error, - message, -}: { - id: number; - error?: any; - message?: any; -}) { - await logToAxiom({ - name: 'image-scan-result', - type: 'error', - imageId: id, - message, - stack: error?.stack, - cause: error?.cause, + const { data } = await getWorkflow({ + client: internalOrchestratorClient, + path: { workflowId: event.workflowId }, }); -} + if (!data) throw new Error(`could not find workflow: ${event.workflowId}`); -// Tag Preprocessing -// -------------------------------------------------- -const tagPreprocessors: Partial IncomingTag[]>> = { - [TagSource.WD14]: processWDTags, - [TagSource.Hive]: processHiveTags, - [TagSource.Clavata]: processClavataTags, - [TagSource.SpineRating]: processSpineRatingTags, -}; + const imageId = data.metadata?.imageId as number | undefined; + if (!imageId) throw new Error(`missing workflow metadata.imageId - ${event.workflowId}`); -const clavataTagConfidenceRequirements: Record = { - daipers: 70, - urine: 60, // need to make it so that we can check wd14 tags for values that negate this tag - ie. cum - unconscious: 60, - 'graphic language': 70, - 'light violence': 70, - hypnosis: 70, - minimum: 51, - minimumNsfw: 51, -}; - -type NsfwLevelTag = (typeof nsfwLevelTags)[number]; -const nsfwLevelTags = ['pg', 'pg-13', 'r', 'x', 'xxx'] as const; - -function processSpineRatingTags(tags: IncomingTag[]) { - return tags; -} - -function processClavataTags(tags: IncomingTag[]) { - // Map tags to lowercase - tags = tags.map((tag) => ({ - ...tag, - confidence: tag.confidence ?? 70, - tag: tag.tag.toLowerCase(), - })); - - // Filter out tags - let highestNsfwTag: IncomingTag = { tag: 'pg', confidence: 100 }; - tags = tags.filter((tag) => { - const nsfwLevelIndex = nsfwLevelTags.indexOf(tag.tag as NsfwLevelTag); - const isNsfwLevel = nsfwLevelIndex !== -1; - - // Remove tags below confidence threshold - const minimumConfidence = isNsfwLevel - ? clavataTagConfidenceRequirements.minimumNsfw - : clavataTagConfidenceRequirements.minimum; - const requiredConfidence = clavataTagConfidenceRequirements[tag.tag] ?? minimumConfidence; - if (tag.confidence < requiredConfidence) return false; - - // Remove nsfw tags - if (isNsfwLevel) { - if (nsfwLevelIndex > nsfwLevelTags.indexOf(highestNsfwTag.tag as NsfwLevelTag)) - highestNsfwTag = tag; - return false; - } - - return true; - }); - - // Add back nsfw tag - tags.push(highestNsfwTag); - - return tags; -} - -function processWDTags(tags: IncomingTag[]) { - return tags.map((tag) => { - tag.tag = tag.tag.replace(/_/g, ' '); - return tag; - }); -} - -const hiveProcessing = { - ignore: ['general_not_nsfw_not_suggestive', 'natural'], - map: { - general_nsfw: 'nsfw', - general_suggestive: 'suggestive', - } as Record, -}; - -function processHiveTags(tags: IncomingTag[]) { - const results: IncomingTag[] = []; - - for (const tag of tags) { - // Handle ignored tags - if (tag.tag.startsWith('no_') || hiveProcessing.ignore.includes(tag.tag)) continue; - - // Clean tag name - if (tag.tag.startsWith('yes_')) tag.tag = tag.tag.slice(4); - else if (hiveProcessing.map[tag.tag]) tag.tag = hiveProcessing.map[tag.tag]; - tag.tag = tag.tag.replace(/_/g, ' '); - - results.push(tag); - } - return results; -} - -// Moderation Rules -// -------------------------------------------------- -async function checkModerationRules(image: GetImageReturn, tags: TagDetails[]) { - const imageModRules = await getImagesModRules(); - if (!imageModRules.length) return; - - const tagNames = tags.map((x) => x.name); - const appliedRule = evaluateRules(imageModRules, { ...image.meta, tags: tagNames }); - if (!appliedRule || appliedRule.action === ModerationRuleAction.Approve) return; - - const data: Prisma.ImageUpdateInput = { - metadata: { ...image.metadata, ruleId: appliedRule.id, ruleReason: appliedRule.reason }, - }; - if (appliedRule.action === ModerationRuleAction.Block) { - data.ingestion = ImageIngestionStatus.Blocked; - data.nsfwLevel = NsfwLevel.Blocked; - data.blockedFor = BlockedReason.Moderated; - data.needsReview = null; - } else if (appliedRule.action === ModerationRuleAction.Hold) { - data.needsReview = 'modRule'; - } - - // Send notification to user if auto blocked - if (appliedRule.action === ModerationRuleAction.Block) { - await createNotification({ - category: NotificationCategory.System, - key: `image-block:${image.id}`, - type: 'system-message', - userId: image.userId, - details: { - message: `One of your images has been blocked due to a moderation rule violation${ - appliedRule.reason ? ` by the following reason: ${appliedRule.reason}` : '' - }. If you believe this is a mistake, you can appeal this decision.`, - url: `/images/${image.id}`, - }, - }).catch((error) => - logToAxiom({ - name: 'image-scan-result', - type: 'error', - message: 'Could not create notification when blocking image', - data: { - imageId: image.id, - error: error.message, - cause: error.cause, - stack: error.stack, - }, - }) - ); - } - - return data; -} - -type GetImageReturn = AsyncReturnType; -async function getImage(id: number) { - const image = await dbWrite.image.findUnique({ - where: { id }, - select: { - id: true, - createdAt: true, - scannedAt: true, - type: true, - userId: true, - meta: true, - metadata: true, - postId: true, - nsfwLevelLocked: true, - scanJobs: true, - ingestion: true, - // nsfwLevel: true, - tools: { - select: { - toolId: true, - }, - }, - }, - }); - if (!image) { - await logScanResultError({ id, message: 'Image not found' }); - throw new Error('Image not found'); - } - return { - ...image, - meta: image.meta as Prisma.JsonObject | undefined, - metadata: image.metadata as ImageMetadata | VideoMetadata | undefined, - scanJobs: (image.scanJobs ?? {}) as { scans?: Record }, - }; -} - -// Tag Fetching -// -------------------------------------------------- -async function getTagsFromIncomingTags({ - id, - tags: incomingTags = [], - source, -}: { - id: number; - tags: BodyProps['tags']; - source: BodyProps['source']; -}) { - const localTagCache: Record = {}; - - if (!incomingTags) { - await logToAxiom({ - type: 'image-scan-result', - message: 'No tags found', - imageId: id, - source, - }); - return; - } - - const image = await getImage(id); - // Preprocess tags - const preprocessor = tagPreprocessors[source]; - if (preprocessor) incomingTags = preprocessor(incomingTags); - - // Add prompt based tags - const imageMeta = image.meta as Prisma.JsonObject | undefined; - const prompt = imageMeta?.prompt as string | undefined; - if (prompt) { - // Detect real person in prompt. Same moderator-managed benign phrases the audit - // path strips (`auditImageScanResults`) β€” without this a whitelisted proper noun - // is still written as a confidence-100 tag, which reaches the search index. - const realPersonName = includesPoi( - // Normalized first, as both audit paths do. Stripping the raw text while the audit that - // runs next reads the normalized copy means two different alphabets decide what counts - // as whitelisted for the same prompt. - await stripBenignPhrases(normalizeText(prompt), BlocklistType.PromptBenignPhrase) - ); - if (realPersonName) { - const tagName = - typeof realPersonName === 'object' ? realPersonName.matchedText : realPersonName; - incomingTags.push({ tag: tagName.toLowerCase(), confidence: 100 }); - } - - // Detect tags from prompt - const promptTags = getTagsFromPrompt(prompt); - if (promptTags) incomingTags.push(...promptTags.map((tag) => ({ tag, confidence: 70 }))); - } - - // De-dupe incoming tags and keep tag with highest confidence - const tagMap: Record = {}; - for (const tag of incomingTags) { - if (!tagMap[tag.tag] || tagMap[tag.tag].confidence < tag.confidence) tagMap[tag.tag] = tag; - } - const tags: Tag[] = Object.values(tagMap); - - // Add computed tags - const computedTags = getComputedTags( - tags.map((x) => x.tag), - source - ); - tags.push(...computedTags.map((x) => ({ tag: x, confidence: 70, source: TagSource.Computed }))); - - // Apply Tag Rules - const tagRules = await getTagRules(); - for (const rule of tagRules) { - const match = tags.find((x) => x.tag === rule.toTag); - if (!match) continue; - - if (rule.type === 'Replace') { - match.id = rule.fromId; - match.tag = rule.fromTag; - } else if (rule.type === 'Append') { - tags.push({ id: rule.fromId, tag: rule.fromTag, confidence: 70, source: TagSource.Computed }); - } - } - - // Get Ids for tags - const tagsToFind: string[] = []; - let hasBlockedTag = false; - const blockedTags: number[] = []; - for (const tag of tags) { - const cachedTag = localTagCache[tag.tag]; - if (!cachedTag) tagsToFind.push(tag.tag); - else { - tag.id = cachedTag.id; - if (!cachedTag.ignored && cachedTag.blocked) { - hasBlockedTag = true; - blockedTags.push(tag.id); - } - } - } - - // Get tags that we don't have cached - if (tagsToFind.length > 0) { - const foundTags = await dbWrite.tag.findMany({ - where: { name: { in: tagsToFind } }, - select: { id: true, name: true, nsfwLevel: true }, - }); - - // Cache found tags and add ids to tags - for (const tag of foundTags) { - localTagCache[tag.name] = { id: tag.id }; - if (tag.nsfwLevel === NsfwLevel.Blocked) localTagCache[tag.name].blocked = true; - if (shouldIgnore(tag.name, source)) localTagCache[tag.name].ignored = true; - } - - for (const tag of tags) { - const cachedTag = localTagCache[tag.tag]; - if (!cachedTag) continue; - tag.id = cachedTag.id; - if (!cachedTag.ignored && cachedTag.blocked) { - hasBlockedTag = true; - blockedTags.push(tag.id); - } - } - } - - // Add missing tags - const newTags = tags.filter((x) => !x.id); - if (newTags.length > 0) { - await dbWrite.tag.createMany({ - data: newTags.map((x) => ({ - name: x.tag, - type: TagType.Label, - target: - source === TagSource.WD14 - ? [TagTarget.Image] - : [TagTarget.Image, TagTarget.Post, TagTarget.Model, TagTarget.Model3D], - })), - }); - const newFoundTags = await dbWrite.tag.findMany({ - where: { name: { in: newTags.map((x) => x.tag) } }, - select: { id: true, name: true, nsfwLevel: true }, - }); - for (const tag of newFoundTags) { - localTagCache[tag.name] = { id: tag.id }; - const match = tags.find((x) => x.tag === tag.name); - if (match) match.id = tag.id; - } - } - - if (tags.length > 0) { - const uniqTags = uniqBy(tags, (x) => x.id); - const toInsert = uniqTags - .filter((x) => x.id) - .map((x) => ({ - imageId: id, - tagId: x.id!, - source: x.source ?? source, - automated: true, - confidence: x.confidence, - disabled: shouldIgnore(x.tag, x.source ?? source), - })); - const fn = source === TagSource.Clavata ? upsertTagsOnImageNew : insertTagsOnImageNew; - await fn(toInsert); - } - - return { image, tags, hasBlockedTag, blockedTags }; -} - -// Review Condition Processing -// -------------------------------------------------- -type Reviewer = 'moderators' | 'knights'; -type ReviewConditionStored = { - reviewer: Reviewer; - condition: string; - tags?: string[]; -}; -type ReviewConditionContext = { - tags: IncomingTag[]; -}; -type ReviewConditionFn = { - reviewer: Reviewer; - condition: (tag: IncomingTag, ctx: ReviewConditionContext) => boolean; - tags?: string[]; -}; -type ReviewCondition = ReviewConditionStored | ReviewConditionFn; - -const reviewConditions: ReviewCondition[] = [ - { - condition: (tag, ctx) => - tag.tag === 'unconscious' && ctx.tags.some((t) => ['r', 'x', 'xxx'].includes(t.tag)), - reviewer: 'moderators', - tags: ['unconscious'], - }, - { - condition: (tag, ctx) => tag.tag === 'bestiality' && ctx.tags.some((t) => t.tag === 'animal'), - reviewer: 'moderators', - tags: ['bestiality'], - }, - // { - // condition: (tag, ctx) => tag.tag === 'bestiality' && ctx.tags.some((t) => t.tag === 'animal'), - // reviewer: 'knights', - // }, - { - condition: (tag) => env.MODERATION_KNIGHT_TAGS.includes(tag.tag), - reviewer: 'knights', - }, -]; - -export function determineReviewer(ctx: ReviewConditionContext) { - for (const tag of ctx.tags) { - for (const { condition, reviewer, tags = [] } of reviewConditions) { - const conditionFn = - typeof condition === 'function' - ? condition - : (new Function('tag', 'ctx', `return ${condition}`) as ( - tag: Tag, - ctx: ReviewConditionContext - ) => boolean); - // Return the first action that matches - if (conditionFn(tag, ctx)) return { reviewer, tagNames: tags }; - } - } -} - -type TagDetails = { - id: number; - name: string; - nsfwLevel: number; - confidence: number; - blocked?: boolean; -}; - -const ImageScanTypeTagSourceMap = new Map([ - [ImageScanType.WD14, TagSource.WD14], - [ImageScanType.Hash, TagSource.ImageHash], - [ImageScanType.Hive, TagSource.Hive], - [ImageScanType.MinorDetection, TagSource.MinorDetection], - [ImageScanType.HiveDemographics, TagSource.HiveDemographics], - [ImageScanType.Clavata, TagSource.Clavata], - [ImageScanType.SpineRating, TagSource.SpineRating], -]); - -async function updateImageScanJobs({ - id, - source, - aiRating, - aiModel, - pHash, - ingestion, - nsfwLevel, - blockedFor, - incrementRetryCount, - whereIngestionIn, -}: { - id: number; - source?: TagSource; - aiRating?: NsfwLevel; - aiModel?: string; - pHash?: bigint; - ingestion?: ImageIngestionStatus; - nsfwLevel?: NsfwLevel; - blockedFor?: string; - incrementRetryCount?: boolean; - whereIngestionIn?: ImageIngestionStatus[]; -}) { - // Build scanJobs update SQL by composing jsonb operations - const scanJobsOps: { path: Prisma.Sql; value: Prisma.Sql }[] = []; - if (source) { - scanJobsOps.push({ - path: Prisma.sql`'{scans}'`, - value: Prisma.sql`COALESCE("scanJobs"->'scans', '{}') || jsonb_build_object(${source}::text, ${String( - Date.now() - )}::bigint)`, - }); - } - if (incrementRetryCount) { - scanJobsOps.push({ - path: Prisma.sql`'{retryCount}'`, - value: Prisma.sql`to_jsonb(COALESCE(("scanJobs"->>'retryCount')::int, 0) + 1)`, - }); - } - if (!scanJobsOps.length) { - throw new Error('updateImageScanJobs requires either source or incrementRetryCount'); - } - // Nest jsonb_set calls: jsonb_set(jsonb_set(base, path1, val1), path2, val2) - const scanJobsSql = scanJobsOps.reduce( - (acc, op) => Prisma.sql`jsonb_set(${acc}, ${op.path}, ${op.value})`, - Prisma.sql`COALESCE("scanJobs", '{}')` - ); - - const setClauses: Prisma.Sql[] = []; - if (pHash !== undefined) setClauses.push(Prisma.sql`"pHash" = ${pHash}`); - if (ingestion) setClauses.push(Prisma.sql`"ingestion" = ${ingestion}::"ImageIngestionStatus"`); - if (nsfwLevel) setClauses.push(Prisma.sql`"nsfwLevel" = ${nsfwLevel}`); - if (blockedFor) setClauses.push(Prisma.sql`"blockedFor" = ${blockedFor}`); - if (aiRating) setClauses.push(Prisma.sql`"aiNsfwLevel" = ${aiRating}`); - if (aiModel) setClauses.push(Prisma.sql`"aiModel" = ${aiModel}`); - setClauses.push(Prisma.sql`"scanJobs" = ${scanJobsSql}`); - - const whereConditions = [Prisma.sql`id = ${id}`]; - if (whereIngestionIn?.length) { - whereConditions.push( - Prisma.sql`ingestion IN (${Prisma.join( - whereIngestionIn.map((s) => Prisma.sql`${s}::"ImageIngestionStatus"`) - )})` - ); - } - - const result = await dbWrite.$queryRaw< - { - scanJobs: { scans?: Record }; - type: MediaType; - }[] - >` - UPDATE "Image" SET - ${Prisma.join(setClauses, ', ')} - WHERE ${Prisma.join(whereConditions, ' AND ')} - RETURNING "scanJobs", type; - `; - - return result[0]?.type === 'video' - ? getHasRequiredVideoScans(result[0]?.scanJobs?.scans) - : getHasRequiredScans(result[0]?.scanJobs?.scans); -} - -const requiredScans = imageScanTypes - .map((type) => ImageScanTypeTagSourceMap.get(type)) - .filter(isDefined); -function getHasRequiredScans(scans: Record = {}) { - return requiredScans.every((scan) => scan in scans); -} -function getHasRequiredVideoScans(scans: Record = {}) { - return [ - ImageScanTypeTagSourceMap.get(ImageScanType.WD14), - ImageScanTypeTagSourceMap.get(ImageScanType.SpineRating), - ].every((scan) => scan! in scans); -} - -async function processScanResult({ - id, - tags: incomingTags = [], - source, - context, - hash, -}: BodyProps) { - switch (source) { - case TagSource.ImageHash: { - if (!hash) throw new Error('missing hash from ImageHash scan'); - const pHash = parsePerceptualHash(hash); - if (pHash === undefined) { - logToAxiom( - { - name: 'image-phash-match', - type: 'warning', - message: 'blank hash from ImageHash scan', - imageId: id, - source: 'webhook-legacy', - }, - 'webhooks' - ).catch(() => null); - } - // Skipped at zero because an all-zero hash is degenerate, not because it cannot match: it - // matches every blocked entry with under 5 set bits, and those matches say nothing about - // the image. Anything acting on this result rather than logging it needs that decided - // properly β€” a flat image currently bypasses the check. Stored either way. - const blocked = !pHash - ? false - : await isBlocked(pHash).catch((error) => { - logToAxiom( - { - name: 'image-phash-match', - type: 'warning', - message: 'pHash blocklist check failed', - imageId: id, - error: error instanceof Error ? error.message : 'Unknown error', - source: 'webhook-legacy', - }, - 'webhooks' - ).catch(() => null); - return false; - }); - if (blocked) { - logToAxiom( - { - name: 'image-phash-match', - type: 'info', - message: 'Image pHash matched a blocked image', - imageId: id, - pHash: pHash?.toString(), - source: 'webhook-legacy', - }, - 'webhooks' - ).catch(() => null); - - // Before uncommenting: `isBlocked` swallows a ClickHouse failure as "not blocked", and - // does not retry. Both are only safe while nothing acts on the result. - // return await updateImageScanJobs({ - // id, - // source, - // pHash, - // ingestion: ImageIngestionStatus.Blocked, - // nsfwLevel: NsfwLevel.Blocked, - // blockedFor: 'Similar to blocked content', - // }); - } - - return await updateImageScanJobs({ id, source, pHash }); - } - case TagSource.WD14: - case TagSource.Clavata: - case TagSource.Hive: - case TagSource.SpineRating: { - await getTagsFromIncomingTags({ id, source, tags: incomingTags }); - - // Add to scanJobs and update aiRating - let aiRating: NsfwLevel | undefined; - let aiModel: string | undefined; - if (source === TagSource.WD14 && !!context?.movie_rating) { - aiRating = NsfwLevel[context.movie_rating as keyof typeof NsfwLevel]; - aiModel = context.movie_rating_model_id; - } - - return await updateImageScanJobs({ id, source, aiRating, aiModel }); - } - default: - throw new Error(`unhandled image scan type: ${source}`); - } -} - -type AuditImageScanResultsReturn = AsyncReturnType; -async function auditImageScanResults({ image }: { image: GetImageReturn }) { - // Moderator-managed benign phrases (proper nouns / technical terms that coincidentally - // contain a detection token) are blanked up front so every downstream check β€” minor, - // poi, blockedFor β€” sees the same cleaned text. - const [prompt, negativePrompt] = await Promise.all([ - stripBenignPhrases( - normalizeText(image.meta?.['prompt'] as string | undefined), - BlocklistType.PromptBenignPhrase - ), - stripBenignPhrases( - normalizeText(image.meta?.['negativePrompt'] as string | undefined), - BlocklistType.NegativeBenignPhrase - ), - ]); - - const tagsFromTagsOnImageDetails = await dbWrite.$queryRaw< - { id: number; name: string; nsfwLevel: number; confidence: number }[] - >` - SELECT t.id, t.name, t."nsfwLevel", toi.confidence - FROM "TagsOnImageDetails" toi - JOIN "Tag" t ON t.id = toi."tagId" - WHERE toi."imageId" = ${image.id} AND toi.automated AND NOT toi.disabled - `; - const tags = tagsFromTagsOnImageDetails.map((tag) => ({ - ...tag, - blocked: tag.nsfwLevel === NsfwLevel.Blocked, - })); - const nsfwLevel = Math.max(...[...tags.map((x) => x.nsfwLevel), 0]); - - let reviewKey: string | null = null; - const flags = { - hasAdultTag: false, - hasMinorTag: false, - hasCartoonTag: false, - nsfw: false, - minor: false, - poi: false, - minorReview: false, - poiReview: false, - tagReview: false, - newUserReview: false, - modRuleReview: false, + const steps: unknown[] = data.steps ?? []; + const input = { + workflowId: event.workflowId, + status: event.status, + imageId, + articleImageScanning: getFeatureFlagsLazy({ req }).articleImageScanning, + startedAt: data.startedAt, + completedAt: data.completedAt, }; - const minorTags: TagDetails[] = []; - const poiTags: TagDetails[] = tags.filter((tag) => poiWords.includes(tag.name.toLowerCase())); - const reviewTags: TagDetails[] = tags.filter((x) => x.blocked); - const hasBlockedTag = reviewTags.length > 0; - - for (const tag of tags) { - if (nsfwLevel > sfwBrowsingLevelsFlag) flags.nsfw = true; - if (tagsNeedingReview.includes(tag.name)) { - flags.hasMinorTag = true; - flags.minor = true; - minorTags.push(tag); - } else if (styleTags.includes(tag.name)) flags.hasCartoonTag = true; - else if (['adult'].includes(tag.name)) flags.hasAdultTag = true; - } - - const nsfwLevelTag = tags.find((x) => nsfwLevelTags.includes(x.name as NsfwLevelTag))?.name as - | NsfwLevelTag - | undefined; - - const child10 = tags.find((x) => x.name === 'child-10'); - const child13 = tags.find((x) => x.name === 'child-13'); - const child15 = tags.find((x) => x.name === 'child-15'); - const realistic = tags.find((x) => x.name === 'realistic'); - const potentialCelebrity = tags.find((x) => x.name === 'potential celebrity'); - - if (nsfwLevelTag) { - switch (nsfwLevelTag) { - case 'pg': - if (child10) flags.minor = true; - if (realistic && child15) { - flags.minor = true; - flags.minorReview = true; - minorTags.push(realistic, child15); - } - break; - case 'pg-13': - case 'r': - case 'x': - case 'xxx': - if (child10) { - flags.minor = true; - flags.minorReview = true; - minorTags.push(child10); - } - if ((child10 || child13 || child15) && realistic) { - flags.minor = true; - flags.minorReview = true; - minorTags.push(...[child10, child13, child15, realistic].filter(isDefined)); - } - break; - } - - if (potentialCelebrity) { - flags.poiReview = true; - poiTags.push(potentialCelebrity); - } - } - - if (poiTags.length > 0) { - flags.poiReview = true; - } - - const inappropriate = includesInappropriate({ prompt, negativePrompt }, flags.nsfw); - if (inappropriate === 'minor') flags.minorReview = true; - if (inappropriate === 'poi') flags.poiReview = true; - if (prompt && includesPoi(prompt)) flags.poi = true; - if (hasBlockedTag) flags.tagReview = true; - - // TODO - add information to ImageForReview entity based on any connected entities - // `hasResource` is still selected for legacy debugging/visibility but is - // no longer consumed now that AI-verification blocking is gone. - const [{ poi, minor }] = await dbWrite.$queryRaw< - { poi: boolean; minor: boolean; hasResource: boolean }[] - >` - WITH to_check AS ( - -- Check based on associated resources - SELECT - SUM(IIF(m.poi, 1, 0)) > 0 "poi", - SUM(IIF(m.minor, 1, 0)) > 0 "minor", - true "hasResource" - FROM "ImageResourceNew" ir - JOIN "ModelVersion" mv ON ir."modelVersionId" = mv.id - JOIN "Model" m ON m.id = mv."modelId" - WHERE ir."imageId" = ${image.id} - UNION - -- Check based on associated bounties - SELECT - SUM(IIF(b.poi, 1, 0)) > 0 "poi", - false "minor", - false "hasResource" - FROM "Image" i - JOIN "ImageConnection" ic ON ic."imageId" = i.id - JOIN "Bounty" b ON ic."entityType" = 'Bounty' AND b.id = ic."entityId" - WHERE ic."imageId" = ${image.id} - UNION - -- Check based on associated bounty entries - SELECT - SUM(IIF(b.poi, 1, 0)) > 0 "poi", - false "minor", - false "hasResource" - FROM "Image" i - JOIN "ImageConnection" ic ON ic."imageId" = i.id - JOIN "BountyEntry" be ON ic."entityType" = 'BountyEntry' AND be.id = ic."entityId" - JOIN "Bounty" b ON b.id = be."bountyId" - WHERE ic."imageId" = ${image.id} - ) - SELECT bool_or(poi) "poi", bool_or(minor) "minor", bool_or("hasResource") "hasResource" FROM to_check; - `; - - if (poi) { - flags.poi = true; - if (flags.nsfw) flags.poiReview = true; - } - - if (minor) { - flags.minor = true; - if (flags.nsfw) flags.minorReview = true; - } - - if (flags.hasMinorTag && !flags.hasAdultTag && (!flags.hasCartoonTag || flags.nsfw)) { - flags.minorReview = true; - } - - if (!flags.minorReview && !flags.poiReview && !flags.tagReview && flags.nsfw) { - // If user is new and image is NSFW send it for review - const [{ isNewUser }] = - (await dbWrite.$queryRaw<{ isNewUser: boolean }[]>` - SELECT is_new_user(CAST(${image.userId} AS INT)) "isNewUser"; - `) ?? []; - if (isNewUser) flags.newUserReview = true; - } - - // Don't need to determine reviewer - const reviewerResult = determineReviewer({ - tags: tags.map((tag) => ({ ...tag, tag: tag.name })), - }); - if (reviewerResult) { - const { reviewer, tagNames } = reviewerResult; - if (reviewer === 'moderators') { - flags.tagReview = true; - reviewTags.push(...tags.filter((x) => tagNames.includes(x.name))); - } - } - - if (flags.poiReview) reviewKey = 'poi'; - else if (flags.minorReview) reviewKey = 'minor'; - else if (flags.tagReview) reviewKey = 'tag'; - else if (flags.newUserReview) reviewKey = 'newUser'; - - let ingestion: ImageIngestionStatus | undefined; - - if (image.type === 'video' && getHasRequiredVideoScans(image.scanJobs?.scans)) { - ingestion = ImageIngestionStatus.Scanned; - } - - if (image.type !== 'video' && getHasRequiredScans(image.scanJobs?.scans)) { - ingestion = ImageIngestionStatus.Scanned; - } - - const data: Prisma.ImageUpdateInput = { - ingestion, - updatedAt: new Date(), - }; - if (!image.nsfwLevelLocked) data.nsfwLevel = nsfwLevel; - if (flags.poi) data.poi = true; - if (flags.minor) data.minor = true; - - // AI-generation verification is no longer a blocking gate (per operations - // 2026-05-11). Hard audit violations (TOS / Moderated / CSAM) still come - // through the `auditMetaData` branch below; nsfw-without-metadata - // falls through to Scanned with whatever `needsReview` tag the scanner - // assigned. - - if (flags.nsfw && prompt && data.ingestion !== ImageIngestionStatus.Blocked) { - // Determine if we need to block the image - const { success, blockedFor } = auditMetaData({ prompt }, flags.nsfw); - if (!success) { - data.ingestion = ImageIngestionStatus.Blocked; - data.blockedFor = blockedFor?.join(',') ?? 'Failed audit, no explanation'; - } - } - - if (data.ingestion === ImageIngestionStatus.Scanned) { - // only set "needsReview" if the scan is successful - if (reviewKey) data.needsReview = reviewKey; - - const createdAtTime = new Date(image.createdAt).getTime(); - const now = new Date(); - const oneWeekAgo = decreaseDate(now, 7, 'days').getTime(); - if (!image.scannedAt) data.scannedAt = now; - else if ( - !(image.metadata as any)?.skipScannedAtReassignment && - image.ingestion !== 'Rescan' && - createdAtTime >= oneWeekAgo - ) - data.scannedAt = now; - } - - const moderationRulesImageData = await checkModerationRules(image, tags); - if (typeof moderationRulesImageData?.needsReview === 'string') - reviewKey = moderationRulesImageData?.needsReview; - - return { - prompt, - data: removeEmpty({ ...data, ...moderationRulesImageData }), - flags, - reviewKey, - tagsForReview: [...minorTags, ...poiTags, ...reviewTags], - scans: image.scanJobs.scans, - }; + return { imageScanning: isImageScanningWorkflow(steps), input: { ...input, steps } }; } diff --git a/src/pages/api/webhooks/reingest-images.ts b/src/pages/api/webhooks/reingest-images.ts deleted file mode 100644 index 074a7add51..0000000000 --- a/src/pages/api/webhooks/reingest-images.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from 'next'; -import * as z from 'zod'; -import { Prisma } from '@prisma/client'; -import { dbWrite } from '~/server/db/client'; -import type { IngestImageInput } from '~/server/schema/image.schema'; -import { ingestImageBulk } from '~/server/services/image.service'; -import { WebhookEndpoint } from '~/server/utils/endpoint-helpers'; - -const schema = z.object({ - imageIds: z.array(z.number()).min(1), - lowPriority: z.boolean().default(true), -}); - -export default WebhookEndpoint(async function (req: NextApiRequest, res: NextApiResponse) { - if (req.method !== 'POST') return res.status(405).json({ error: 'Method Not Allowed' }); - - const { imageIds, lowPriority } = schema.parse(req.body); - - const images = await dbWrite.$queryRaw` - SELECT id, url, type, width, height, meta->>'prompt' as prompt - FROM "Image" - WHERE id IN (${Prisma.join(imageIds)}) - `; - - if (!images.length) { - return res.status(404).json({ error: 'No images found for the provided IDs' }); - } - - const foundIds = images.map((i) => i.id); - - const success = await ingestImageBulk({ images, lowPriority }); - - res.status(200).json({ success, count: images.length, imageIds: foundIds }); -}); diff --git a/src/server/flipt/client.ts b/src/server/flipt/client.ts index 0be474f3f9..0d826a434e 100644 --- a/src/server/flipt/client.ts +++ b/src/server/flipt/client.ts @@ -138,6 +138,11 @@ export enum FLIPT_FEATURE_FLAGS { // OFF is the shipped default and means the pattern list is recorded but not enforced on these // surfaces. The link-domain half throws either way β€” this flag has never governed it. USER_CONTENT_PATTERN_ENFORCE = 'user-content-pattern-enforce', + + // Submits image ingestion as one imageScanning step instead of wdTagging + mediaRating. + // DEFAULT-OFF β€” an unknown flag or unreachable Flipt keeps the two-step path. Evaluated + // with the imageId and no context, so ramp by percentage or boolean; a segment matches nothing. + IMAGE_INGESTION_IMAGE_SCANNING = 'image-ingestion-image-scanning', } // Flags exempt from caching: incident kill-switches where an operator expects a diff --git a/src/server/games/new-order/pool-quotas.ts b/src/server/games/new-order/pool-quotas.ts index ae9cc0bbf3..a287292ddc 100644 --- a/src/server/games/new-order/pool-quotas.ts +++ b/src/server/games/new-order/pool-quotas.ts @@ -3,7 +3,7 @@ import { NewOrderRankType } from '~/shared/utils/prisma/enums'; /** * Built-in fallback for `poolQuotas` when Redis config hasn't seeded one. * Knight is the only rank that suffers from the SFW/NSFW priority misuse - * today (see `image-scan-result.ts` routing), so we default it to a 50/50 + * today (see `addToNewOrderQueue` in `image-scan-pipeline.ts`), so we default it to a 50/50 * split between Knight1 (SFW) and Knight2 (NSFW). Acolyte/Templar are * intentionally omitted to preserve their legacy sequential behavior. */ diff --git a/src/server/jobs/image-ingestion.ts b/src/server/jobs/image-ingestion.ts index 5e8115d618..04fb37ffed 100644 --- a/src/server/jobs/image-ingestion.ts +++ b/src/server/jobs/image-ingestion.ts @@ -15,12 +15,9 @@ import { decreaseDate } from '~/utils/date-helpers'; const IMAGE_SCANNING_ERROR_DELAY = 60 * 1; // 1 hour const IMAGE_SCANNING_RETRY_LIMIT = 9; -// Hard per-image backstop for a single submit. The orchestrator submit is already -// bounded per-attempt (createImageIngestionRequest, ~15s AbortSignal), but this also -// covers any other external await inside ingestImage (Redis flag read, prompt lookup, -// the scanJobs UPDATE) so one hung submit ties up a single concurrency slot rather -// than the whole run. A fired timeout just fails that image β†’ it stays queued and is -// retried on a later run. +// Hard per-image backstop. The orchestrator submit is bounded per attempt (~15s), but the +// Flipt flag read before it and the scanJobs UPDATE after it are not, so this keeps one hung +// image to one concurrency slot. A timed-out image stays queued for a later run. const INGEST_IMAGE_TIMEOUT_MS = 60 * 1000; // Per-run wall-clock budget. Once exceeded we stop STARTING new submits so the run @@ -702,9 +699,9 @@ export const removeBlockedImages = createJob( // DOES NOT RETRACT β€” the row is still hard-deleted here, exactly as before; only the shared // object is left alone: // β€’ the scan pipeline's three block outcomes β€” the orchestrator content rating - // (`blockImageFromRating`), the prompt/text audit, and the moderation rule engine β€” in - // BOTH copies of that pipeline: `image-scan-result.service` and the legacy bodies in - // `api/webhooks/image-scan-result`. Automated, no moderator, no `ModActivity`. + // (`blockImageFromRating`), the prompt/text audit, and the moderation rule engine + // (`image-scan-result.service` / `image-scan-pipeline`). Automated, no moderator, no + // `ModActivity`. // β€’ the CSAM branch of `report.service` β€” reached from `report.create`, which is a // `guardedProcedureAllowUnverifiedEmail`, so it is fired by ANY reporting user's report // and not by a moderator reviewing one. This is the writer that would be most dangerous diff --git a/src/server/redis/buffer-decode.ts b/src/server/redis/buffer-decode.ts index 313cd1f469..1196bb833a 100644 --- a/src/server/redis/buffer-decode.ts +++ b/src/server/redis/buffer-decode.ts @@ -6,8 +6,8 @@ * a read back to a utf8 string before treating it as one. No-op when the value is * already a string (single-node / dev) or null/undefined. * - * Same coercion as the earlier per-site fixes (redis/queues.ts, image-scanner-flag.ts, - * metrics/base.metrics.ts). Prefer this helper for new call sites. + * Same coercion as the earlier per-site fixes (redis/queues.ts, metrics/base.metrics.ts). + * Prefer this helper for new call sites. */ export function decodeRedisString(value: T | Buffer): T { return (Buffer.isBuffer(value) ? value.toString('utf8') : value) as T; diff --git a/src/server/routers/image.router.ts b/src/server/routers/image.router.ts index 833f5e2f6e..6e9519c2b8 100644 --- a/src/server/routers/image.router.ts +++ b/src/server/routers/image.router.ts @@ -19,7 +19,6 @@ import { getImagesByUserIdForModeration, getImagesForModelVersionCache, getMyImages, - ingestArticleCoverImages, ingestImageById, removeImageResource, removeImageTechniques, @@ -95,10 +94,6 @@ const isOwnerOrModerator = middleware(async ({ ctx, next, input = {} }) => { // TODO.cleanup - remove unused router methods export const imageRouter = router({ - ingestArticleImages: protectedProcedure - .meta({ requiredScope: TokenScope.MediaWrite }) - .input(z.array(z.object({ imageId: z.number(), articleId: z.number() }))) - .mutation(({ input }) => ingestArticleCoverImages(input)), moderate: moderatorProcedure.input(imageModerationSchema).mutation(moderateImageHandler), delete: verifiedProcedure .meta({ requiredScope: TokenScope.MediaDelete }) diff --git a/src/server/services/__tests__/image-scanner-flag.buffer.test.ts b/src/server/services/__tests__/image-scanner-flag.buffer.test.ts deleted file mode 100644 index 010558867b..0000000000 --- a/src/server/services/__tests__/image-scanner-flag.buffer.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -// Regression: `isImageScannerNewEnabled` reads a kill-switch from sysRedis and -// compared the reply against the literals '1'/'true'/'0'/'false'. The -// HA/Sentinel sysRedis returns a Buffer for BLOB_STRING replies, which matched -// none of the four literals β†’ the function fell through and DESTRUCTIVELY -// overwrote the operator's '1' with 'false' (then returned false). The pure -// decision is extracted into its own `image-scanner-flag` module so the -// coercion + the seed-only-on-genuinely-unset behavior is testable without -// loading the 7.9K-line image.service module (which drags in Prisma/env/auth -// at import time and can't load under vitest). null return == "unset" == the -// only case the caller is allowed to default-seed. - -import { parseScannerFlag } from '~/server/services/image-scanner-flag'; - -describe('parseScannerFlag β€” sysRedis Buffer-vs-string flag', () => { - it('Buffer("1") β†’ true (was a destructive fall-through pre-fix)', () => { - expect(parseScannerFlag(Buffer.from('1', 'utf8'))).toBe(true); - }); - - it('Buffer("true") β†’ true', () => { - expect(parseScannerFlag(Buffer.from('true', 'utf8'))).toBe(true); - }); - - it('Buffer("0") β†’ false', () => { - expect(parseScannerFlag(Buffer.from('0', 'utf8'))).toBe(false); - }); - - it('Buffer("false") β†’ false', () => { - expect(parseScannerFlag(Buffer.from('false', 'utf8'))).toBe(false); - }); - - it('string "1"/"true" β†’ true (legacy single-pod, unchanged)', () => { - expect(parseScannerFlag('1')).toBe(true); - expect(parseScannerFlag('true')).toBe(true); - }); - - it('string "0"/"false" β†’ false (legacy single-pod, unchanged)', () => { - expect(parseScannerFlag('0')).toBe(false); - expect(parseScannerFlag('false')).toBe(false); - }); - - it('null β†’ null (genuinely unset β†’ caller seeds the default)', () => { - expect(parseScannerFlag(null)).toBeNull(); - }); - - it('an unrecognized value β†’ null (treated as unset, not a destructive match)', () => { - expect(parseScannerFlag(Buffer.from('garbage', 'utf8'))).toBeNull(); - expect(parseScannerFlag('garbage')).toBeNull(); - }); -}); diff --git a/src/server/services/__tests__/image-scanning-result.service.test.ts b/src/server/services/__tests__/image-scanning-result.service.test.ts new file mode 100644 index 0000000000..6d805b1c59 --- /dev/null +++ b/src/server/services/__tests__/image-scanning-result.service.test.ts @@ -0,0 +1,435 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + classifyImageScanFailure, + ImageScanFailureClass, +} from '~/server/services/image-scan-failure'; + +const pipeline = vi.hoisted(() => ({ + applyIngestionSideEffects: vi.fn(), + buildAndInsertScanTags: vi.fn(), + extractFailedSteps: vi.fn(), + loadImageForScan: vi.fn(), + logPerceptualHashMatch: vi.fn(), + markImageScanError: vi.fn(), + readJobFailureReason: vi.fn(), + resolveScanOutcome: vi.fn(), + sendIngestionSignal: vi.fn(), +})); +const { mockRecordAudit, mockRemoveJobQueue, mockFanOut } = vi.hoisted(() => ({ + mockRecordAudit: vi.fn(), + mockRemoveJobQueue: vi.fn(), + mockFanOut: vi.fn(), +})); + +// The pipeline imports the whole ingestion stack, and its stages run for real in +// image-scan-result.test.ts, so this suite pins only what this service passes them. +vi.mock('~/server/services/image-scan-pipeline', () => pipeline); +vi.mock('~/server/services/orchestrator/orchestrator.service', () => ({ + computePerceptualHash: (hash?: string) => (hash ? BigInt(`0x${hash}`) : undefined), +})); +vi.mock('~/server/services/scanner-audit.service', () => ({ + recordImageScanningResult: mockRecordAudit, +})); +vi.mock('~/server/services/job-queue.service', () => ({ + removeImageScanJobQueue: mockRemoveJobQueue, +})); +vi.mock('~/server/utils/webhook-debounce', () => ({ fanOutArticleImageUpdates: mockFanOut })); + +import { + IMAGE_SCANNING_LOG, + isImageScanningWorkflow, + parseImageScanningSteps, + processImageScanningWorkflow, +} from '~/server/services/image-scanning-result.service'; + +const scanStep = { + $type: 'imageScanning', + name: 'scan', + status: 'succeeded', + output: { + nsfwLevel: 'pg13', + tagging: { ran: true, tags: [{ tag: 'solo', category: 'general', score: 0.9 }] }, + jointAgeClassification: { detections: [], minorDetected: false }, + csam: null, + }, +}; +const failedScanStep = { ...scanStep, status: 'failed', output: undefined }; +const hashStep = { $type: 'mediaHash', output: { hashes: { perceptual: '0A' } } }; +const image = { id: 7, userId: 3, meta: { prompt: 'a prompt' } }; + +const deliver = (status: string, steps: unknown[]) => + processImageScanningWorkflow({ + workflowId: 'wf', + status, + steps, + imageId: 7, + articleImageScanning: true, + startedAt: 'start', + completedAt: 'end', + }); + +// Every failure must be recorded exactly once: a second write bumps retryCount again and +// overwrites the failure type the retry ceiling is chosen from. +const expectOneScanError = (failure: Record) => { + expect(pipeline.markImageScanError).toHaveBeenCalledTimes(1); + expect(pipeline.markImageScanError).toHaveBeenCalledWith(expect.objectContaining(failure)); + expect(pipeline.sendIngestionSignal).toHaveBeenCalledTimes(1); + expect(pipeline.sendIngestionSignal).toHaveBeenCalledWith({ + imageId: 7, + userId: 3, + ingestion: 'Error', + log: IMAGE_SCANNING_LOG, + }); + expect(mockFanOut).toHaveBeenCalledWith(7); + expect(pipeline.buildAndInsertScanTags).not.toHaveBeenCalled(); +}; + +describe('processImageScanningWorkflow', () => { + beforeEach(() => { + vi.clearAllMocks(); + pipeline.loadImageForScan.mockResolvedValue(image); + pipeline.resolveScanOutcome.mockResolvedValue({ + ingestion: 'Scanned', + blockedFor: null, + reviewKey: null, + }); + pipeline.markImageScanError.mockResolvedValue({ userId: 3, failureClass: 'unknown' }); + pipeline.extractFailedSteps.mockReturnValue(['scan']); + pipeline.readJobFailureReason.mockResolvedValue('Failed to create container'); + }); + + it('writes the scan through the shared pipeline without a hard block', async () => { + await deliver('succeeded', [scanStep, hashStep]); + + expect(pipeline.logPerceptualHashMatch).toHaveBeenCalledWith({ + imageId: 7, + pHash: 10n, + log: IMAGE_SCANNING_LOG, + }); + expect(pipeline.buildAndInsertScanTags).toHaveBeenCalledWith({ + imageId: 7, + wdTags: { solo: 0.9 }, + ratingLevel: 'pg13', + prompt: 'a prompt', + }); + expect(pipeline.resolveScanOutcome).toHaveBeenCalledWith({ + image, + pHash: 10n, + workflowId: 'wf', + prompt: 'a prompt', + negativePrompt: undefined, + log: IMAGE_SCANNING_LOG, + }); + expect(mockRemoveJobQueue).toHaveBeenCalledWith([7]); + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'wf', + imageId: 7, + startedAt: 'start', + completedAt: 'end', + scan: expect.objectContaining({ nsfwLevel: 'pg13', csam: null }), + }) + ); + expect(pipeline.applyIngestionSideEffects).toHaveBeenCalledWith({ + image, + outcome: expect.objectContaining({ ingestion: 'Scanned' }), + }); + expect(mockFanOut).toHaveBeenCalledWith(7); + expect(pipeline.markImageScanError).not.toHaveBeenCalled(); + expect(pipeline.sendIngestionSignal).toHaveBeenCalledTimes(1); + expect(pipeline.sendIngestionSignal).toHaveBeenCalledWith({ + imageId: 7, + userId: 3, + ingestion: 'Scanned', + blockedFor: null, + log: IMAGE_SCANNING_LOG, + }); + }); + + it('signals the outcome the pipeline resolved, blocked included', async () => { + pipeline.resolveScanOutcome.mockResolvedValue({ + ingestion: 'Blocked', + blockedFor: 'moderated', + reviewKey: null, + }); + await deliver('succeeded', [scanStep]); + + expect(pipeline.sendIngestionSignal).toHaveBeenCalledWith({ + imageId: 7, + userId: 3, + ingestion: 'Blocked', + blockedFor: 'moderated', + log: IMAGE_SCANNING_LOG, + }); + }); + + it.each([ + ['failed', 'workflow-failed'], + ['expired', 'expired'], + ['canceled', 'canceled'], + ])('marks a %s workflow as one %s error', async (status, failureType) => { + const steps = [failedScanStep]; + await deliver(status, steps); + + expect(pipeline.extractFailedSteps).toHaveBeenCalledWith(steps); + expect(pipeline.readJobFailureReason).toHaveBeenCalledWith('wf'); + expect(pipeline.markImageScanError).toHaveBeenCalledWith({ + workflowId: 'wf', + imageId: 7, + status, + failureType, + failedSteps: ['scan'], + reason: 'Failed to create container', + }); + expectOneScanError({ failureType }); + }); + + it('marks a succeeded workflow without tags as one unusable-result error', async () => { + await deliver('succeeded', [ + { ...scanStep, output: { ...scanStep.output, tagging: { status: 'skipped', ran: false } } }, + ]); + + expectOneScanError({ + failureType: 'unusable-result', + reason: expect.stringContaining('Tagging did not run'), + }); + expect(pipeline.loadImageForScan).not.toHaveBeenCalled(); + }); + + it('marks a processing failure as one processing-failed error', async () => { + pipeline.buildAndInsertScanTags.mockRejectedValueOnce(new Error('tag insert failed')); + await deliver('succeeded', [scanStep]); + + expect(pipeline.markImageScanError).toHaveBeenCalledTimes(1); + expect(pipeline.markImageScanError).toHaveBeenCalledWith( + expect.objectContaining({ failureType: 'processing-failed', reason: 'tag insert failed' }) + ); + expect(pipeline.sendIngestionSignal).toHaveBeenCalledTimes(1); + expect(mockRecordAudit).not.toHaveBeenCalled(); + }); + + it.each([ + ['succeeded', [scanStep]], + ['failed', [failedScanStep]], + ])('leaves articles alone when article scanning is off (%s)', async (status, steps) => { + await processImageScanningWorkflow({ + workflowId: 'wf', + status, + steps, + imageId: 7, + articleImageScanning: false, + }); + expect(mockFanOut).not.toHaveBeenCalled(); + }); + + it('lets a deleted image through for the route to acknowledge', async () => { + pipeline.loadImageForScan.mockRejectedValueOnce(new Error('image not found: 7')); + await expect(deliver('succeeded', [scanStep])).rejects.toThrow('image not found'); + expect(pipeline.markImageScanError).not.toHaveBeenCalled(); + }); + + it('keeps the verdict when a side effect fails', async () => { + mockRemoveJobQueue.mockRejectedValueOnce(new Error('queue down')); + await deliver('succeeded', [scanStep]); + + expect(pipeline.markImageScanError).not.toHaveBeenCalled(); + expect(pipeline.sendIngestionSignal).toHaveBeenCalledWith( + expect.objectContaining({ ingestion: 'Scanned' }) + ); + }); +}); + +const detection = (ageBand: string, isMinor: boolean) => ({ + ageBand, + isMinor, + under18Probability: isMinor ? 0.8 : 0.1, +}); + +const scanOutput = (overrides: Record = {}) => ({ + nsfwLevel: 'r', + score: 0.8, + topK: [], + aiRecognition: { label: 'AI', score: 0.9 }, + animeRecognition: { label: 'real', score: 0.7 }, + tagging: { + status: 'ran', + ran: true, + tags: [ + { tag: 'solo', category: 'general', score: 0.9 }, + { tag: 'lowres', category: 'meta', score: 0.8 }, + { tag: 'worst quality', category: 'quality', score: 0.8 }, + { tag: 'original', category: 'copyright', score: 0.8 }, + { tag: 'hatsune miku', category: 'character', score: 0.8 }, + { tag: 'questionable', category: 'rating', score: 0.8 }, + ], + }, + jointAgeClassification: { + status: 'ran', + ran: true, + detections: [detection('21-24', false)], + minorDetected: false, + }, + csam: false, + ...overrides, +}); + +const parserHashStep = { + $type: 'mediaHash', + output: { hashes: { perceptual: '6F51B11C49611E0E' } }, +}; +const parserScanStep = (output: unknown) => ({ $type: 'imageScanning', output }); +const frames = (...outputs: unknown[]) => ({ + $type: 'repeat', + output: { steps: outputs.map(parserScanStep) }, +}); + +const untaggedFrame = scanOutput({ tagging: { status: 'skipped', ran: false, tags: [] } }); +const unratedFrame = scanOutput({ nsfwLevel: 'na' }); + +describe('parseImageScanningSteps', () => { + it('reads an image scan, keeping only general tags', () => { + expect(parseImageScanningSteps([parserScanStep(scanOutput()), parserHashStep], 'wf')).toEqual({ + nsfwLevel: 'r', + tags: { solo: 0.9 }, + csam: false, + ageDetections: [detection('21-24', false)], + minorDetected: false, + aiRecognition: { label: 'AI', score: 0.9 }, + animeRecognition: { label: 'real', score: 0.7 }, + perceptualHash: '6F51B11C49611E0E', + }); + }); + + const riskyFrame = scanOutput({ + nsfwLevel: 'x', + csam: true, + aiRecognition: { label: 'AI', score: 0.9 }, + animeRecognition: { label: 'anime', score: 0.95 }, + tagging: { ran: true, tags: [{ tag: 'solo', category: 'general', score: 0.7 }] }, + jointAgeClassification: { + ran: true, + detections: [detection('13-15', true)], + minorDetected: true, + }, + }); + const safeFrame = scanOutput({ + nsfwLevel: 'pg', + csam: null, + aiRecognition: { label: 'Real', score: 0.6 }, + animeRecognition: { label: 'real', score: 0.55 }, + tagging: { ran: true, tags: [{ tag: 'solo', category: 'general', score: 0.6 }] }, + jointAgeClassification: { + ran: true, + detections: [detection('21-24', false)], + minorDetected: false, + }, + }); + + // Both orders, so a frame can only ever raise what the video reports. + it.each([ + ['riskiest frame first', [riskyFrame, safeFrame]], + ['riskiest frame last', [safeFrame, riskyFrame]], + ])('combines video frames by their highest values (%s)', (_, frameOutputs) => { + const scan = parseImageScanningSteps( + [{ $type: 'videoFrameExtraction', output: { frames: [] } }, frames(...frameOutputs)], + 'wf' + ); + + expect(scan).toMatchObject({ + nsfwLevel: 'x', + tags: { solo: 0.7 }, + csam: true, + minorDetected: true, + aiRecognition: { label: 'AI', score: 0.9 }, + animeRecognition: { label: 'anime', score: 0.95 }, + }); + expect(scan.ageDetections).toHaveLength(2); + expect(scan.ageDetections).toEqual( + expect.arrayContaining([detection('13-15', true), detection('21-24', false)]) + ); + }); + + it('reports csam as absent only when no frame reported it', () => { + const csamOf = (...values: unknown[]) => + parseImageScanningSteps([frames(...values.map((csam) => scanOutput({ csam })))], 'wf').csam; + expect(csamOf(null, null)).toBeNull(); + expect(csamOf(null, false)).toBe(false); + expect(csamOf(false, true, null)).toBe(true); + expect(csamOf(true, false)).toBe(true); + }); + + it.each([ + ['tagging did not run', [parserScanStep(untaggedFrame)], 'Tagging did not run'], + ['the rating is unavailable', [parserScanStep(unratedFrame)], 'media rating unavailable'], + [ + 'the rating is one this app does not know', + [parserScanStep(scanOutput({ nsfwLevel: 'nc17' }))], + 'media rating unavailable (nc17)', + ], + ['one frame was not tagged', [frames(scanOutput(), untaggedFrame)], 'Tagging did not run'], + ['one frame has no rating', [frames(scanOutput(), unratedFrame)], 'media rating unavailable'], + [ + 'the scan step has no output', + [{ $type: 'imageScanning', status: 'failed' }], + 'Missing imageScanning output', + ], + [ + 'a frame has no output', + [ + { + $type: 'repeat', + output: { steps: [parserScanStep(scanOutput()), { $type: 'imageScanning' }] }, + }, + ], + 'Missing imageScanning output', + ], + ['no frames were extracted', [frames()], 'Missing imageScanning output'], + ])('throws when %s', (_, steps, message) => { + expect(() => parseImageScanningSteps(steps, 'wf')).toThrow(message); + }); + + // A permanent class stops retries after one attempt; none of these is a bad file. + it.each([[[parserScanStep(untaggedFrame)]], [[parserScanStep(unratedFrame)]], [[frames()]]])( + 'throws an error the retry classifier keeps retrying', + (steps) => { + let reason = ''; + try { + parseImageScanningSteps(steps, 'wf'); + } catch (error) { + reason = (error as Error).message; + } + expect(reason).not.toBe(''); + expect( + classifyImageScanFailure({ reason, failureType: 'unusable-result', failedSteps: [] }) + ).not.toBe(ImageScanFailureClass.Permanent); + } + ); +}); + +describe('isImageScanningWorkflow', () => { + it.each([ + ['a finished image scan', [parserScanStep(scanOutput()), parserHashStep], true], + ['a failed image scan with no output', [{ $type: 'imageScanning', status: 'failed' }], true], + ['a finished video scan', [frames(scanOutput())], true], + [ + 'a video scan that never produced frames', + [{ $type: 'repeat', input: { template: { $type: 'imageScanning' } } }], + true, + ], + [ + 'a legacy image scan', + [{ $type: 'wdTagging' }, { $type: 'mediaRating' }, parserHashStep], + false, + ], + [ + 'a legacy video scan', + [ + { $type: 'repeat', input: { template: { $type: 'wdTagging' } } }, + { $type: 'repeat', input: { template: { $type: 'mediaRating' } } }, + ], + false, + ], + ])('%s β†’ %s', (_, steps, expected) => { + expect(isImageScanningWorkflow(steps)).toBe(expected); + }); +}); diff --git a/src/server/services/__tests__/poi-checks-strip-benign-phrases.test.ts b/src/server/services/__tests__/poi-checks-strip-benign-phrases.test.ts index db5f168ed2..f391afd979 100644 --- a/src/server/services/__tests__/poi-checks-strip-benign-phrases.test.ts +++ b/src/server/services/__tests__/poi-checks-strip-benign-phrases.test.ts @@ -7,7 +7,7 @@ import path from 'path'; * must read the benign-stripped prompt. * * This exists because the fix for that bug was applied to one of two live copies. The image - * scan pipeline has two implementations β€” `getTagsFromIncomingTags` in the webhook (legacy + * scan pipeline had two implementations β€”`getTagsFromIncomingTags` in the webhook (legacy * `TagSource.WD14`/`Clavata`/`Hive` bodies) and `processTags` in the service (bodies carrying * `workflowId`, i.e. the orchestrator) β€” and the webhook dispatches between them by body * shape. Patching the one the audit named left the other writing a confidence-100 POI tag @@ -33,8 +33,8 @@ import path from 'path'; * a new one written as an arrow needs the strip INLINE at the call to be checked properly. * The escape also weakens as the enclosing function grows, since ANY `stripBenignPhrases` * between the declaration and the call satisfies it. An entry whose call strips inline - * should therefore set `requireInline` and give up the escape entirely β€” only the two scan - * files need it, because they shadow `prompt` with the stripped copy further up. + * should therefore set `requireInline` and give up the escape entirely β€” only the scan + * pipeline needs it, because it shadows `prompt` with the stripped copy further up. * 2. One other server-side `includesPoi` call reads a raw prompt and is deliberately NOT * covered: `services/apps/shared-content-safety.ts` throws `SharedContentBlockedError('poi')` * on shared app content, which is a hard legal reject rather than a moderation label. @@ -53,13 +53,7 @@ const REPO_ROOT = path.resolve(__dirname, '../../../..'); */ const GUARDED_FILES: { rel: string; consequence: string; requireInline?: boolean }[] = [ { - rel: 'src/pages/api/webhooks/image-scan-result.ts', - consequence: - 'writes a confidence-100 POI tag that reaches the image search index, so a ' + - 'moderator-whitelisted phrase would still flag the image', - }, - { - rel: 'src/server/services/image-scan-result.service.ts', + rel: 'src/server/services/image-scan-pipeline.ts', consequence: 'writes a confidence-100 POI tag that reaches the image search index, so a ' + 'moderator-whitelisted phrase would still flag the image', @@ -68,7 +62,7 @@ const GUARDED_FILES: { rel: string; consequence: string; requireInline?: boolean rel: 'src/server/services/orchestrator/orchestration-new.service.ts', // The enclosing function is ~300 lines, so the scope escape below would be satisfied by ANY // later `stripBenignPhrases` in it β€” negative-prompt stripping being the obvious one, since - // both scan files pair it with the prompt strip. This call needs no escape, so it gets none. + // the scan pipeline pairs it with the prompt strip. This call needs no escape, so it gets none. requireInline: true, consequence: 'refuses the generation outright for a viewer with `disablePoi`, so a ' + diff --git a/src/server/services/__tests__/scanner-audit.image-scanning.test.ts b/src/server/services/__tests__/scanner-audit.image-scanning.test.ts new file mode 100644 index 0000000000..1e23188e8b --- /dev/null +++ b/src/server/services/__tests__/scanner-audit.image-scanning.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type * as ClickhouseClient from '~/server/clickhouse/client'; + +const { mockInsert } = vi.hoisted(() => ({ mockInsert: vi.fn() })); + +vi.mock('~/server/clickhouse/client', async (importOriginal) => ({ + ...(await importOriginal()), + clickhouse: { insert: mockInsert }, +})); + +import { recordImageScanningResult } from '~/server/services/scanner-audit.service'; + +const detection = (ageBand: string, under18Probability: number, isMinor: boolean) => + ({ ageBand, under18Probability, isMinor } as never); + +const scan = { + nsfwLevel: 'r' as const, + tags: {}, + csam: false, + // The highest under-18 probability is not the first detection. + ageDetections: [detection('21-24', 0.1, false), detection('16-17', 0.7, true)], + minorDetected: true, + aiRecognition: { label: 'AI', score: 0.9 }, + animeRecognition: { label: 'Real', score: 0.6 }, +}; + +type Row = Record; +const insertedRows = () => mockInsert.mock.calls[0][0].values as Row[]; +const row = (label: string) => insertedRows().find((r) => r.label === label); + +describe('recordImageScanningResult', () => { + beforeEach(() => mockInsert.mockReset().mockResolvedValue(undefined)); + + it('writes version 2 rows for the rating, csam, the joint age model and both recognizers', async () => { + await recordImageScanningResult({ workflowId: 'wf', imageId: 1, scan }); + + const rows = insertedRows(); + expect(rows.map((r) => [r.label, r.labelValue, r.score, r.triggered])).toEqual([ + ['r', 'nsfw_level', 1, 1], + ['csam', '', 0, 0], + ['minor', '21-24, 16-17', 0.7, 1], + ['ai', 'ai_recognition', 0.9, 1], + ['real', 'anime_recognition', 0.6, 1], + ]); + expect(rows.every((r) => r.version === '2' && r.modelVersion === '2')).toBe(true); + expect(rows.every((r) => r.scanner === 'image_ingestion')).toBe(true); + }); + + it('records a positive csam result as triggered', async () => { + await recordImageScanningResult({ + workflowId: 'wf', + imageId: 1, + scan: { ...scan, csam: true }, + }); + expect(row('csam')).toMatchObject({ score: 1, triggered: 1 }); + }); + + it('writes no csam row when the scanner did not report one', async () => { + await recordImageScanningResult({ + workflowId: 'wf', + imageId: 1, + scan: { ...scan, csam: null }, + }); + expect(row('csam')).toBeUndefined(); + }); + + it('does not trigger the minor row when only adults were detected', async () => { + await recordImageScanningResult({ + workflowId: 'wf', + imageId: 1, + scan: { + ...scan, + ageDetections: [detection('21-24', 0.1, false), detection('25+', 0.2, false)], + minorDetected: false, + }, + }); + expect(row('minor')).toMatchObject({ labelValue: '21-24, 25+', score: 0.2, triggered: 0 }); + }); + + it('writes no minor row when nobody was detected', async () => { + await recordImageScanningResult({ + workflowId: 'wf', + imageId: 1, + scan: { ...scan, ageDetections: [], minorDetected: false }, + }); + expect(row('minor')).toBeUndefined(); + }); +}); diff --git a/src/server/services/article.service.ts b/src/server/services/article.service.ts index 01578f14c0..ee73c78844 100644 --- a/src/server/services/article.service.ts +++ b/src/server/services/article.service.ts @@ -1863,7 +1863,7 @@ export type ArticleTextModerationStatus = { updatedAt: Date | null; }; -// `scanJobs.error` is stamped by `markImageScanError` (image-scan-result.service, scan +// `scanJobs.error` is stamped by `markImageScanError` (image-scan-pipeline, scan // verdicts) and `markImageScanSubmitFailure` (image.service, submit rejections). Both // carry the classifier's verdict (transient | permanent | unknown) plus the human reason, // letting the scan-status UI render a class-aware cause. diff --git a/src/server/services/games/new-order.service.ts b/src/server/services/games/new-order.service.ts index bd44219161..db6e090f92 100644 --- a/src/server/services/games/new-order.service.ts +++ b/src/server/services/games/new-order.service.ts @@ -1637,7 +1637,7 @@ export async function getImagesQueue({ // Per-rank pool weights drive a stratified fetch instead of the legacy // strict-sequential drain. Knight1/Knight2/Knight3 are content-tier - // buckets in practice (see image-scan-result.ts), so reading Knight1 + // buckets in practice (see addToNewOrderQueue in image-scan-pipeline.ts), so reading Knight1 // until full starved Knight2 (NSFW) entirely. Resolve weights from // Redis (ops-tunable) with a built-in fallback; missing rank β†’ legacy. const rateLimitConfig = await getVotingRateLimitConfig(); diff --git a/src/server/services/image-scan-pipeline.ts b/src/server/services/image-scan-pipeline.ts new file mode 100644 index 0000000000..c13cd4136b --- /dev/null +++ b/src/server/services/image-scan-pipeline.ts @@ -0,0 +1,962 @@ +import poiWords from '~/utils/metadata/lists/words-poi.json'; +import { dbWrite } from '~/server/db/client'; +import { clickhouse } from '~/server/clickhouse/client'; +import { env } from '~/env/server'; +import type { TagType } from '~/shared/utils/prisma/enums'; +import { + ImageIngestionStatus, + ModerationRuleAction, + NewOrderRankType, + TagSource, +} from '~/shared/utils/prisma/enums'; +import { + BlockedReason, + BlocklistType, + NotificationCategory, + NsfwLevel, + SearchIndexUpdateQueueAction, + SignalMessages, +} from '~/server/common/enums'; +import { stripBenignPhrases } from '~/server/services/blocklist.service'; +import { + auditMetaData, + getTagsFromPrompt, + includesInappropriate, + includesPoi, +} from '~/utils/metadata/audit'; +import { getComputedTags, getConditionalTagsForReview } from '~/server/utils/tag-rules'; +import { getTagRules } from '~/server/services/system-cache'; +import { Prisma } from '@prisma/client'; +import { insertTagsOnImageNew } from '~/server/services/tagsOnImageNew.service'; +import { isDefined } from '~/utils/type-guards'; +import { normalizeText } from '~/utils/normalize-text'; +import { styleTags, tagsNeedingReview } from '~/libs/tags'; +import { sfwBrowsingLevelsFlag } from '~/shared/constants/browsingLevel.constants'; +import { createImageTagsForReview } from '~/server/services/image-review.service'; +import { + tagIdsForImagesCache, + tagCacheByName, + userImageVideoCountCaches, +} from '~/server/redis/caches'; +import type { RedisKeyTemplateSys } from '~/server/redis/client'; +import { REDIS_SYS_KEYS, sysRedis } from '~/server/redis/client'; +import { classifyImageScanFailure } from '~/server/services/image-scan-failure'; +import type { MediaMetadata } from '~/server/schema/media.schema'; +import { deleteUserProfilePictureCache } from '~/server/services/user.service'; +import { bustCachesForPosts, updatePostNsfwLevel } from '~/server/services/post.service'; +import { + queueComicsForPanelImage, + updateComicNsfwLevelsForImage, + updateModel3DNsfwLevelForThumbnailImage, +} from '~/server/services/nsfwLevels.service'; +import { getImagesModRules, queueImageSearchIndexUpdate } from '~/server/services/image.service'; +import { signalClient } from '~/utils/signal-client'; +import { addImageToQueue } from '~/server/services/games/new-order.service'; +import { logToAxiom } from '~/server/logging/client'; +import { evaluateRules } from '~/server/utils/mod-rules'; +import { createNotification } from '~/server/services/notification.service'; +import { decreaseDate } from '~/utils/date-helpers'; +import { withRetries } from '~/utils/errorHandling'; + +// Ingestion stages shared by the legacy (wdTagging + mediaRating) and imageScanning result handlers. + +type NormalizedTag = { + name: string; + confidence: number; + source: TagSource; +}; + +type TagWithId = { id: number; name: string; nsfwLevel: number; type: TagType }; +type ProcessedTag = { + source: TagSource; + confidence: number; + id: number; + name: string; + nsfwLevel: number; + type: TagType; +}; + +// TTL for a stashed job reason. Comfortably longer than the orchestrator's 10-min +// workflow expiry so the terminal workflow event can still read it. +const JOB_REASON_TTL_SECONDS = 15 * 60; + +const jobReasonKey = (workflowId: string) => + `${REDIS_SYS_KEYS.WEBHOOKS.IMAGE_SCAN_JOB_REASON}:${workflowId}` as RedisKeyTemplateSys; + +/** Orchestrator job-level event shape we care about (a subset of WorkflowStepJobEvent). */ +export type OrchestratorJobEvent = { + $type?: string; + workflowId?: string; + jobId?: string; + reason?: string | null; +}; + +// Stash the job's failure `reason` keyed by workflowId. No DB/orchestrator round-trip: +// just a short-lived Redis write so the terminal workflow event can classify. +// +// Correlation contract: job events fire before the terminal workflow event, so the +// reason is stashed by the time the workflow event reads it. Across a workflow's +// several jobs this is last-write-wins (fine β€” image scans typically have one failing +// job; the reason strings we classify on are equivalent). If the reason is missing +// (race, or a job event that never arrived) the failure classifies as Unknown, which +// retries conservatively under the bounded cap β€” safe by construction. The TTL bounds +// a stash that never gets a following workflow event so it can't linger. +export async function captureJobFailureReason(event: OrchestratorJobEvent) { + const workflowId = event.workflowId; + const reason = typeof event.reason === 'string' ? event.reason.trim() : ''; + if (!workflowId || !reason) return; + await sysRedis + .set(jobReasonKey(workflowId), reason, { EX: JOB_REASON_TTL_SECONDS }) + .catch(() => null); +} + +export async function readJobFailureReason(workflowId: string): Promise { + const reason = await sysRedis.get(jobReasonKey(workflowId)).catch(() => null); + if (reason) sysRedis.del(jobReasonKey(workflowId)).catch(() => null); + return reason ?? null; +} + +/** Axiom `name`/`source` a pipeline's logs carry, so each pipeline can be queried alone. */ +export type ScanLog = { name: string; source: string }; +export const LEGACY_SCAN_LOG: ScanLog = { + name: 'image-scan-result', + source: 'image-scan-result.service', +}; + +/** + * Push the resolved ingestion state to the uploader's open editor. The editor renders + * Pending as an in-progress spinner, so any state that LEAVES Pending has to send β€” + * Error included, retryable though it is β€” or the card goes on claiming to analyze + * until the page is reloaded. + */ +export async function sendIngestionSignal({ + imageId, + userId, + ingestion, + blockedFor, + log = LEGACY_SCAN_LOG, +}: { + imageId: number; + userId: number | null; + ingestion: ImageIngestionStatus; + blockedFor?: string | null; + log?: ScanLog; +}) { + if (!userId) return; + await signalClient + .send({ + target: SignalMessages.ImageIngestionStatus, + data: { imageId, ingestion, blockedFor }, + userId, + }) + .catch((error) => + logToAxiom( + { + name: log.name, + type: 'warning', + message: `signal send failed: ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + imageId, + source: log.source, + }, + 'webhooks' + ).catch(() => null) + ); +} + +// Blocked-content detection +// -------------------------------------------------- +async function getIsImageBlocked(hash: bigint) { + if (!env.BLOCKED_IMAGE_HASH_CHECK || !clickhouse) return false; + + const client = clickhouse; + // $query has no retry of its own, and the observed failure is a dropped socket. + const rows = await withRetries( + () => client.$query<{ count: number }>` + SELECT cast(count() as int) as count + FROM blocked_images + WHERE bitCount(bitXor(hash, ${hash})) < 5 AND disabled = false + `, + 2, + 250 + ); + + return (rows?.[0]?.count ?? 0) > 0; +} + +export async function logPerceptualHashMatch({ + imageId, + pHash, + log = LEGACY_SCAN_LOG, +}: { + imageId: number; + pHash: bigint; + log?: ScanLog; +}) { + // Nothing branches on this result, so an exhausted retry must not fail the webhook and + // discard a scan that already produced tags and a rating. + const pHashBlocked = await getIsImageBlocked(pHash).catch((error) => { + logToAxiom( + { + name: 'image-phash-match', + type: 'warning', + message: 'pHash blocklist check failed', + imageId, + error: error instanceof Error ? error.message : 'Unknown error', + source: log.source, + }, + 'webhooks' + ).catch(() => null); + return false; + }); + if (!pHashBlocked) return; + + // blockedReason = 'Similar to blocked content'; + logToAxiom( + { + name: 'image-phash-match', + type: 'info', + message: 'Image pHash matched a blocked image', + imageId, + pHash: pHash.toString(), + source: log.source, + }, + 'webhooks' + ).catch(() => null); +} + +/** + * Which orchestrator steps reported `failed`, by name (else `$type`). The workflow status + * carries no reason, so this is the only way to tell a scan-step failure from a `hash` failure. + */ +export function extractFailedSteps(steps: unknown[]): string[] { + return (steps as Array<{ name?: string; $type?: string; status?: string }>) + .filter((step) => step?.status === 'failed') + .map((step) => step.name ?? step.$type ?? 'unknown'); +} + +/** + * Flip an image to `Error`, increment its scan `retryCount`, and stamp a small + * `scanJobs.error = { status, failureType, failedSteps, reason, failureClass, at }` + * so a plain Postgres query can tell WHY a scan errored (and which step) without an + * orchestrator lookup β€” and so the `ingest-images` cron can pick a retry ceiling + * from `failureClass`. retryCount ALWAYS increments: it's the absolute attempt + * count the per-class ceilings are applied against. Returns the new (post-increment) + * retryCount, the image's mediaType, and the computed failureClass; retryCount / + * mediaType are `null` when no row matched (e.g. the image was deleted between scan + * request and callback). + */ +export async function markImageScanError({ + workflowId, + imageId, + status, + failureType, + failedSteps, + reason, + middleware, +}: { + workflowId: string; + imageId: number; + status: string; + failureType: string; + failedSteps: string[]; + /** Human failure reason from the job-level callback, if captured. */ + reason?: string | null; + /** Orchestrator middleware locus, if known. Not exposed on the v2 job event today. */ + middleware?: string | null; +}): Promise<{ + retryCount: number | null; + mediaType: string | null; + userId: number | null; + failureClass: string; +}> { + const failureClass = classifyImageScanFailure({ reason, failureType, middleware, failedSteps }); + // undefined keys are dropped by JSON.stringify, so absent reason/middleware + // simply don't appear in the stored blob. + const errorJson = JSON.stringify({ + status, + failureType, + failedSteps, + reason: reason ?? undefined, + middleware: middleware ?? undefined, + failureClass, + at: new Date().toISOString(), + }); + const rows = await dbWrite.$queryRaw< + { retryCount: number | null; mediaType: string | null; userId: number | null }[] + >` + UPDATE "Image" + SET + "ingestion" = ${ImageIngestionStatus.Error}::"ImageIngestionStatus", + "scanJobs" = jsonb_set( + jsonb_set( + jsonb_set( + COALESCE("scanJobs", '{}'), + '{retryCount}', + to_jsonb(COALESCE(("scanJobs"->>'retryCount')::int, 0) + 1) + ), + '{workflowId}', + ${JSON.stringify(workflowId)}::jsonb + ), + '{error}', + ${errorJson}::jsonb + ) + WHERE id = ${imageId} + RETURNING ("scanJobs"->>'retryCount')::int as "retryCount", type as "mediaType", + "userId" + `; + return { + retryCount: rows[0]?.retryCount ?? null, + mediaType: rows[0]?.mediaType ?? null, + userId: rows[0]?.userId ?? null, + failureClass, + }; +} + +// Image loading +// -------------------------------------------------- +export async function loadImageForScan(imageId: number) { + const image = await dbWrite.image.findUnique({ + where: { id: imageId }, + select: { + id: true, + createdAt: true, + scannedAt: true, + type: true, + userId: true, + meta: true, + metadata: true, + postId: true, + nsfwLevelLocked: true, + nsfwLevel: true, + ingestion: true, + }, + }); + + if (!image) throw new Error(`image not found: ${imageId}`); + return image; +} +export type ScanImage = Awaited>; + +// Tagging +// -------------------------------------------------- +export async function buildAndInsertScanTags({ + imageId, + wdTags, + ratingLevel, + prompt, +}: { + imageId: number; + wdTags: Record; + ratingLevel: string; + prompt?: string; +}) { + const tagsWithSource = { + [TagSource.WD14]: wdTags, + [TagSource.SpineRating]: { [ratingLevel]: 100 }, + }; + const normalizedTags: NormalizedTag[] = Object.entries(tagsWithSource).flatMap( + ([source, tagMap]) => + Object.entries(tagMap).map(([name, confidence]) => { + if (source === TagSource.WD14) name = name.replace(/_/g, ' '); + return { + name, + confidence: Math.round(confidence * 100), + source: source as TagSource, + }; + }) + ); + + const tags = await processTags({ tags: normalizedTags, prompt }); + + await insertTagsOnImageNew( + tags.map((tag) => ({ + imageId, + tagId: tag.id, + source: tag.source, + confidence: tag.confidence, + automated: true, + })) + ); +} + +async function processTags({ + tags: normalized, + prompt, +}: { + tags: NormalizedTag[]; + prompt?: string; +}): Promise { + if (prompt) { + // Strip moderator-whitelisted phrases first, or a whitelisted proper noun is written as a + // confidence-100 POI tag that reaches the image search index. + const realPersonName = includesPoi( + // Normalized first, as both audit paths do. Stripping the raw text while the audit that + // runs next reads the normalized copy means two different alphabets decide what counts + // as whitelisted for the same prompt. + await stripBenignPhrases(normalizeText(prompt), BlocklistType.PromptBenignPhrase) + ); + if (realPersonName) { + const tagName = + typeof realPersonName === 'object' ? realPersonName.matchedText : realPersonName; + normalized.push({ + name: tagName.toLowerCase(), + confidence: 100, + source: TagSource.Computed, + }); + } + + // Detect tags from prompt + const promptTags = getTagsFromPrompt(prompt); + if (promptTags) + normalized.push( + ...promptTags.map((name) => ({ name, confidence: 70, source: TagSource.Computed })) + ); + } + + // add computed tags + const computedTags = getComputedTags( + normalized.map((x) => x.name), + 'WD14' + ); + normalized.push( + ...computedTags.map((name) => ({ name, confidence: 70, source: TagSource.Computed })) + ); + + // apply tag rules + const tagRules = await getTagRules(); + for (const rule of tagRules) { + const match = normalized.find((x) => x.name === rule.toTag); + if (!match) continue; + + if (rule.type === 'Replace') { + match.name = rule.fromTag; + } else if (rule.type === 'Append') { + normalized.push({ name: rule.fromTag, confidence: 70, source: TagSource.Computed }); + } + } + + // De-dupe incoming tags and keep tag with highest confidence + const tagMap: Record = {}; + for (const tag of normalized) { + if (!tagMap[tag.name] || tagMap[tag.name].confidence < tag.confidence) tagMap[tag.name] = tag; + } + const deduped: NormalizedTag[] = Object.values(tagMap); + + const { found, missing } = await tagCacheByName.fetch(deduped.map((x) => x.name)); + let queriedTags: TagWithId[] = []; + if (missing.length > 0) { + queriedTags = await dbWrite.tag.findMany({ + where: { name: { in: missing } }, + select: { id: true, name: true, nsfwLevel: true, type: true }, + }); + await tagCacheByName.setMany(queriedTags.map((data) => ({ key: data.name, data }))); + } + const queriedNames = new Set(queriedTags.map((t) => t.name)); + const tagsToCreate = missing.filter((name) => !queriedNames.has(name)); + + let createdTags: TagWithId[] = []; + if (tagsToCreate.length > 0) { + const tagsToInsert = deduped.filter((x) => tagsToCreate.includes(x.name)); + + // Raw SQL bypasses Prisma's @updatedAt stamp, and neither column has a DB + // default β€” both are NOT NULL, so omitting either raises 23502. + const now = new Date(); + const values = tagsToInsert.map( + (tag) => Prisma.sql`(${tag.name}, ${now}, ARRAY['Image']::"TagTarget"[])` + ); + + createdTags = await dbWrite.$queryRaw` + INSERT INTO "Tag" (name, "updatedAt", target) + VALUES ${Prisma.join(values)} + ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name + RETURNING id, name, "nsfwLevel", type + `; + await tagCacheByName.setMany(createdTags.map((data) => ({ key: data.name, data }))); + } + + const allTags = [...found.values(), ...queriedTags, ...createdTags] + .map((tag) => { + const match = normalized.find((x) => x.name === tag.name); + if (!match) return null; + return { ...tag, source: match.source, confidence: match.confidence }; + }) + .filter(isDefined); + + return allTags; +} + +// Outcome resolution (audit + moderation rules + persist) +// -------------------------------------------------- +export type ScanOutcome = { + ingestion: ImageIngestionStatus; + blockedFor: string | null; + reviewKey: string | null; + /** Present only when the image was audited (i.e. not the `blocked` short-circuit). */ + audit?: Awaited>; +}; + +/** + * Resolve and persist the final state of the image row, returning the bits the + * post-update side effects need. Three terminal shapes: + * - `blocked` (legacy mediaRating hard block, already written by the caller): only + * stamp provenance β€” re-auditing would recompute nsfwLevel/ingestion and silently + * un-block the image. A *prior* block is deliberately not sticky, so a moderator + * rescan can clear it. imageScanning never passes `blocked`, so on that path only + * `audit.blockedFor` and moderation rules re-block. + * - `audit.blockedFor` (prompt TOS/CSAM): block. + * - otherwise: Scanned, with moderation rules applied last so a rule Block/Hold + * takes precedence over the audit decision. + */ +export async function resolveScanOutcome({ + image, + blocked, + pHash, + workflowId, + prompt, + negativePrompt, + log = LEGACY_SCAN_LOG, +}: { + image: ScanImage; + blocked?: { reason: string | null }; + pHash?: bigint; + workflowId: string; + prompt?: string; + negativePrompt?: string; + log?: ScanLog; +}): Promise { + const updatedAt = new Date(); + + if (blocked) { + await dbWrite.$executeRaw` + UPDATE "Image" + SET + "updatedAt" = ${updatedAt}, + "pHash" = COALESCE(${pHash ?? null}, "pHash"), + "scanJobs" = jsonb_set(COALESCE("scanJobs", '{}'), '{workflowId}', ${JSON.stringify( + workflowId + )}::jsonb) + WHERE id = ${image.id} + `; + return { + ingestion: ImageIngestionStatus.Blocked, + blockedFor: blocked.reason, + reviewKey: null, + }; + } + + const audit = await auditScanResults({ + imageId: image.id, + userId: image.userId, + prompt, + negativePrompt, + }); + let reviewKey = audit.reviewKey ?? null; + + const toUpdate: Prisma.ImageUpdateInput = { updatedAt, pHash }; + // AI-generation verification is no longer a blocking gate (per operations + // 2026-05-11): nsfw images that we couldn't auto-verify as AI used to + // land in `Blocked + AiNotVerified`, but the false-positive rate didn't + // justify the friction. The remaining `audit.blockedFor` branch still + // catches hard violations (TOS / Moderated / CSAM) β€” everything else + // falls through to Scanned. + if (audit.blockedFor) { + toUpdate.ingestion = ImageIngestionStatus.Blocked; + toUpdate.blockedFor = audit.blockedFor; + toUpdate.nsfwLevel = NsfwLevel.Blocked; + } else { + toUpdate.ingestion = ImageIngestionStatus.Scanned; + toUpdate.needsReview = reviewKey; + toUpdate.minor = audit.minor; + toUpdate.poi = audit.poi; + toUpdate.blockedFor = null; + // Respect a manually-locked nsfw level β€” never overwrite it from the scan. + toUpdate.nsfwLevel = image.nsfwLevelLocked ? image.nsfwLevel : audit.nsfwLevel; + + // scannedAt reassignment: always stamp the first scan; afterwards only + // re-stamp recent (<1 week old) non-Rescan images that haven't opted out + // via metadata.skipScannedAtReassignment. Older/rescanned images keep + // their original scannedAt. + const now = new Date(); + if (!image.scannedAt) toUpdate.scannedAt = now; + else if ( + !(image.metadata as any)?.skipScannedAtReassignment && + image.ingestion !== 'Rescan' && + new Date(image.createdAt).getTime() >= decreaseDate(now, 7, 'days').getTime() + ) + toUpdate.scannedAt = now; + else toUpdate.scannedAt = image.scannedAt; + } + + // Moderation rules can block the image, hold it for review, or annotate its + // metadata with the matched rule. Applied after the scan audit so a Block/Hold + // takes precedence over the audit's own decision. + const modRule = await evaluateImageModRules(image, audit.tags); + let metadataUpdate: Record | undefined; + if (modRule) { + metadataUpdate = modRule.metadata; + if (modRule.ingestion) toUpdate.ingestion = modRule.ingestion; + if (modRule.nsfwLevel != null) toUpdate.nsfwLevel = modRule.nsfwLevel; + if (modRule.blockedFor) toUpdate.blockedFor = modRule.blockedFor; + if (modRule.needsReview !== undefined) { + toUpdate.needsReview = modRule.needsReview; + if (typeof modRule.needsReview === 'string') reviewKey = modRule.needsReview; + } + // Notify the user only when a rule auto-blocked the image (not on Hold). + if (modRule.ingestion === ImageIngestionStatus.Blocked) { + await notifyImageAutoBlocked({ + imageId: image.id, + userId: image.userId, + reason: modRule.ruleReason, + log, + }); + } + } + + await dbWrite.$executeRaw` + UPDATE "Image" + SET + "updatedAt" = ${toUpdate.updatedAt}, + "pHash" = ${pHash ?? null}, + "ingestion" = ${toUpdate.ingestion as string}::"ImageIngestionStatus", + "blockedFor" = ${(toUpdate.blockedFor as string) ?? null}, + "nsfwLevel" = ${toUpdate.nsfwLevel as number}, + "needsReview" = ${(toUpdate.needsReview as string) ?? null}, + "minor" = ${(toUpdate.minor as boolean) ?? false}, + "poi" = ${(toUpdate.poi as boolean) ?? false}, + "scannedAt" = ${(toUpdate.scannedAt as Date) ?? null}, + "metadata" = COALESCE(${ + metadataUpdate ? JSON.stringify(metadataUpdate) : null + }::jsonb, "metadata"), + "scanJobs" = jsonb_set(COALESCE("scanJobs", '{}'), '{workflowId}', ${JSON.stringify( + workflowId + )}::jsonb) + WHERE id = ${image.id} + `; + + return { + ingestion: toUpdate.ingestion as ImageIngestionStatus, + blockedFor: (toUpdate.blockedFor as string) ?? null, + reviewKey, + audit, + }; +} + +async function auditScanResults(args: { + imageId: number; + userId: number; + prompt?: string; + negativePrompt?: string; +}) { + // Moderator-managed benign phrases (proper nouns / technical terms that coincidentally + // contain a detection token) are blanked up front so every downstream check β€” minor, + // poi, blockedFor β€” sees the same cleaned text. A benign phrase is innocent content, so + // it shouldn't feed any detector. + const [prompt, negativePrompt] = await Promise.all([ + stripBenignPhrases(normalizeText(args.prompt), BlocklistType.PromptBenignPhrase), + stripBenignPhrases(normalizeText(args.negativePrompt), BlocklistType.NegativeBenignPhrase), + ]); + const tags = await dbWrite.$queryRaw< + { id: number; name: string; type: TagType; nsfwLevel: number; confidence: number }[] + >` + SELECT t.id, t.name, t."nsfwLevel", toi.confidence + FROM "TagsOnImageDetails" toi + JOIN "Tag" t ON t.id = toi."tagId" + WHERE toi."imageId" = ${args.imageId} AND toi.automated AND NOT toi.disabled + `; + const nsfwLevel = Math.max(...[...tags.map((x) => x.nsfwLevel), 0]); + const nsfw = nsfwLevel > sfwBrowsingLevelsFlag; + const minorTags = tags.filter((tag) => tagsNeedingReview.includes(tag.name.toLowerCase())); + const poiTags = tags.filter((tag) => poiWords.includes(tag.name.toLowerCase())); + const reviewTags = [ + ...tags.filter((tag) => tag.nsfwLevel === NsfwLevel.Blocked), + ...getConditionalTagsForReview(tags, nsfwLevel), + ]; + const adultTags = tags.filter((tag) => tag.name === 'adult'); + const cartoonTags = tags.filter((tag) => styleTags.includes(tag.name)); + + const tagReview = reviewTags.length > 0; + let poiReview = poiTags.length > 0; + let minorReview = + minorTags.length > 0 && adultTags.length === 0 && (cartoonTags.length === 0 || nsfw); + let newUserReview = false; + + const inappropriate = includesInappropriate({ prompt, negativePrompt }, nsfw); + if (inappropriate === 'minor') minorReview = true; + if (inappropriate === 'poi') poiReview = true; + + const associatedEntities = await getAssociatedEntities(args.imageId); + // Associated poi/minor resources only escalate to review when the image is + // nsfw β€” a sfw image with a poi/minor resource is still flagged (below) but + // not queued for moderator review. + if (associatedEntities.poi && nsfw) poiReview = true; + if (associatedEntities.minor && nsfw) minorReview = true; + + if (!minorReview && !poiReview && !tagReview && nsfw) { + newUserReview = await getIsNewUser(args.userId); + } + + const minor = minorTags.length > 0 || !!associatedEntities.minor; + const poi = poiTags.length > 0 || !!associatedEntities.poi || (!!prompt && !!includesPoi(prompt)); + + let reviewKey: string | undefined; + if (poiReview) reviewKey = 'poi'; + else if (minorReview) reviewKey = 'minor'; + else if (tagReview) reviewKey = 'tag'; + else if (newUserReview) reviewKey = 'newUser'; + + let blockedFor: string | undefined; + if (nsfw && prompt) { + const auditResult = auditMetaData({ prompt }, nsfw); + if (!auditResult.success) + blockedFor = auditResult.blockedFor.join(',') ?? 'Failed audit, no explanation'; + } + + return { + tags, + nsfwLevel, + nsfw, + minorTags, + poiTags, + reviewTags, + tagReview, + poiReview, + minorReview, + newUserReview, + minor, + poi, + reviewKey, + blockedFor, + }; +} + +async function getAssociatedEntities(imageId: number) { + const [result] = await dbWrite.$queryRaw< + { poi: boolean; minor: boolean; hasResource: boolean }[] + >` + WITH to_check AS ( + -- Check based on associated resources + SELECT + SUM(IIF(m.poi, 1, 0)) > 0 "poi", + SUM(IIF(m.minor, 1, 0)) > 0 "minor", + true "hasResource" + FROM "ImageResourceNew" ir + JOIN "ModelVersion" mv ON ir."modelVersionId" = mv.id + JOIN "Model" m ON m.id = mv."modelId" + WHERE ir."imageId" = ${imageId} + UNION + -- Check based on associated bounties + SELECT + SUM(IIF(b.poi, 1, 0)) > 0 "poi", + false "minor", + false "hasResource" + FROM "Image" i + JOIN "ImageConnection" ic ON ic."imageId" = i.id + JOIN "Bounty" b ON ic."entityType" = 'Bounty' AND b.id = ic."entityId" + WHERE ic."imageId" = ${imageId} + UNION + -- Check based on associated bounty entries + SELECT + SUM(IIF(b.poi, 1, 0)) > 0 "poi", + false "minor", + false "hasResource" + FROM "Image" i + JOIN "ImageConnection" ic ON ic."imageId" = i.id + JOIN "BountyEntry" be ON ic."entityType" = 'BountyEntry' AND be.id = ic."entityId" + JOIN "Bounty" b ON b.id = be."bountyId" + WHERE ic."imageId" = ${imageId} + ) + SELECT bool_or(poi) "poi", bool_or(minor) "minor", bool_or("hasResource") "hasResource" FROM to_check; + `; + + return result; +} + +async function getIsNewUser(userId: number) { + const [{ isNewUser }] = + (await dbWrite.$queryRaw<{ isNewUser: boolean }[]>` + SELECT is_new_user(CAST(${userId} AS INT)) "isNewUser"; + `) ?? []; + return isNewUser; +} + +// Moderation rules +// -------------------------------------------------- +/** + * Evaluate the image moderation rules and return the row changes they imply + * (block / hold / metadata annotation). Pure of side effects β€” the caller is + * responsible for persisting the changes and, on a Block, calling + * `notifyImageAutoBlocked`. + */ +async function evaluateImageModRules( + image: { meta: Prisma.JsonValue; metadata: Prisma.JsonValue }, + tags: { name: string }[] +) { + const imageModRules = await getImagesModRules(); + if (!imageModRules.length) return; + + const tagNames = tags.map((x) => x.name); + const meta = (image.meta ?? {}) as Prisma.JsonObject; + const appliedRule = evaluateRules(imageModRules, { ...meta, tags: tagNames }); + if (!appliedRule || appliedRule.action === ModerationRuleAction.Approve) return; + + const result: { + metadata: Record; + ruleReason?: string | null; + ingestion?: ImageIngestionStatus; + nsfwLevel?: NsfwLevel; + blockedFor?: string; + needsReview?: string | null; + } = { + metadata: { + ...((image.metadata ?? {}) as Record), + ruleId: appliedRule.id, + ruleReason: appliedRule.reason, + }, + ruleReason: appliedRule.reason, + }; + + if (appliedRule.action === ModerationRuleAction.Block) { + result.ingestion = ImageIngestionStatus.Blocked; + result.nsfwLevel = NsfwLevel.Blocked; + result.blockedFor = BlockedReason.Moderated; + result.needsReview = null; + } else if (appliedRule.action === ModerationRuleAction.Hold) { + result.needsReview = 'modRule'; + } + + return result; +} + +async function notifyImageAutoBlocked({ + imageId, + userId, + reason, + log, +}: { + imageId: number; + userId: number; + reason?: string | null; + log: ScanLog; +}) { + await createNotification({ + category: NotificationCategory.System, + key: `image-block:${imageId}`, + type: 'system-message', + userId, + details: { + message: `One of your images has been blocked due to a moderation rule violation${ + reason ? ` by the following reason: ${reason}` : '' + }. If you believe this is a mistake, you can appeal this decision.`, + url: `/images/${imageId}`, + }, + }).catch((error) => + logToAxiom({ + name: log.name, + type: 'error', + message: 'Could not create notification when blocking image', + data: { + imageId, + error: error.message, + cause: error.cause, + stack: error.stack, + }, + }) + ); +} + +// Post-update side effects +// -------------------------------------------------- +export async function applyIngestionSideEffects({ + image, + outcome, +}: { + image: ScanImage; + outcome: ScanOutcome; +}) { + // handle blocked image updates + if (outcome.ingestion === ImageIngestionStatus.Blocked) { + await queueImageSearchIndexUpdate({ + ids: [image.id], + action: SearchIndexUpdateQueueAction.Delete, + }); + // A previously-cached Blocked image can still satisfy the showcase query + // filters (needsReview IS NULL, nsfwLevel != 0) so drop it from the showcase. + if (image.postId) await bustCachesForPosts(image.postId); + await updateModel3DNsfwLevelForThumbnailImage({ imageId: image.id, postId: image.postId }); + // If this image belongs to a comic panel, the parent project may + // have been search-indexed under the old (unblocked) state. Re-queue + // it so the next index pass re-evaluates visibility against the + // moderation gates in `comics.search-index.ts:WHERE`. + await queueComicsForPanelImage(image.id); + return; + } + + // handle scanned image updates + if (outcome.ingestion === ImageIngestionStatus.Scanned) { + // Scanning is what makes an already-published image countable. Bust rather + // than refresh: this fires once per image, so a re-query here would be N + // identical counts for an N-image post. + await userImageVideoCountCaches.bust(image.userId); + await tagIdsForImagesCache.refresh(image.id); + if ( + typeof image.metadata === 'object' && + (image.metadata as MediaMetadata | undefined)?.profilePicture + ) { + await deleteUserProfilePictureCache(image.userId); + } + + if (image.postId) { + await updatePostNsfwLevel(image.postId); + // Without this, the showcase cache stays empty until its 24h TTL for any model version whose images hadn't scanned yet on first read. + await bustCachesForPosts(image.postId); + } + await updateModel3DNsfwLevelForThumbnailImage({ imageId: image.id, postId: image.postId }); + await updateComicNsfwLevelsForImage(image.id); + // Refresh the comic project in the search index β€” even on a clean + // Scanned, `needsReview` may have been set, which the index treats + // as a visibility gate. + await queueComicsForPanelImage(image.id); + + await queueImageSearchIndexUpdate({ + ids: [image.id], + action: SearchIndexUpdateQueueAction.Update, + }); + + const { audit, reviewKey } = outcome; + if (audit) { + const tagsForReview = [...audit.poiTags, ...audit.minorTags, ...audit.reviewTags]; + // Only persist review-tags when the image is actually queued for review + // (matches the legacy `if (reviewKey)` gate). + if (reviewKey && tagsForReview.length > 0) { + await createImageTagsForReview({ + imageId: image.id, + tagIds: tagsForReview.map((x) => x.id), + }); + } + + if (!reviewKey && image.type === 'image') { + await addToNewOrderQueue({ imageId: image.id, nsfw: audit.nsfw }); + } + } + } +} + +const KONO_NSFW_SAMPLING_RATE = 0.3; // 30% +async function addToNewOrderQueue({ imageId, nsfw }: { imageId: number; nsfw: boolean }) { + let shouldAddToQueue = true; + let priority: 1 | 2 | 3 = 1; + const rankType = NewOrderRankType.Knight; + if (nsfw) { + priority = 2; + shouldAddToQueue = Math.random() < KONO_NSFW_SAMPLING_RATE; + } + if (shouldAddToQueue) { + await addImageToQueue({ + imageIds: [imageId], + rankType, + priority, + }); + } +} diff --git a/src/server/services/image-scan-result.service.ts b/src/server/services/image-scan-result.service.ts index 67011b3331..f6d2aea575 100644 --- a/src/server/services/image-scan-result.service.ts +++ b/src/server/services/image-scan-result.service.ts @@ -1,73 +1,27 @@ -import poiWords from '~/utils/metadata/lists/words-poi.json'; -import { getWorkflow, type MediaRatingOutput, type WorkflowEvent } from '@civitai/client'; -import type { NextApiRequest } from 'next'; +import type { MediaRatingOutput } from '@civitai/client'; import { dbWrite } from '~/server/db/client'; -import { internalOrchestratorClient } from '~/server/services/orchestrator/client'; import { computePerceptualHash } from '~/server/services/orchestrator/orchestrator.service'; -import { clickhouse } from '~/server/clickhouse/client'; -import { env } from '~/env/server'; -import type { TagType } from '~/shared/utils/prisma/enums'; -import { - ImageIngestionStatus, - ModerationRuleAction, - NewOrderRankType, - TagSource, -} from '~/shared/utils/prisma/enums'; -import { - BlockedReason, - BlocklistType, - NotificationCategory, - NsfwLevel, - SearchIndexUpdateQueueAction, - SignalMessages, -} from '~/server/common/enums'; -import { stripBenignPhrases } from '~/server/services/blocklist.service'; -import { - auditMetaData, - getTagsFromPrompt, - includesInappropriate, - includesPoi, -} from '~/utils/metadata/audit'; -import { getComputedTags, getConditionalTagsForReview } from '~/server/utils/tag-rules'; -import { getTagRules } from '~/server/services/system-cache'; -import { Prisma } from '@prisma/client'; -import { insertTagsOnImageNew } from '~/server/services/tagsOnImageNew.service'; +import { ImageIngestionStatus } from '~/shared/utils/prisma/enums'; +import { NsfwLevel } from '~/server/common/enums'; import { isDefined } from '~/utils/type-guards'; -import { normalizeText } from '~/utils/normalize-text'; -import { styleTags, tagsNeedingReview } from '~/libs/tags'; -import { - orchestratorNsfwLevelMap, - sfwBrowsingLevelsFlag, -} from '~/shared/constants/browsingLevel.constants'; -import { createImageTagsForReview } from '~/server/services/image-review.service'; -import { - tagIdsForImagesCache, - tagCacheByName, - userImageVideoCountCaches, -} from '~/server/redis/caches'; -import type { RedisKeyTemplateSys } from '~/server/redis/client'; -import { REDIS_SYS_KEYS, sysRedis } from '~/server/redis/client'; -import { classifyImageScanFailure } from '~/server/services/image-scan-failure'; +import { orchestratorNsfwLevelMap } from '~/shared/constants/browsingLevel.constants'; import type { MediaMetadata } from '~/server/schema/media.schema'; -import { deleteUserProfilePictureCache } from '~/server/services/user.service'; -import { bustCachesForPosts, updatePostNsfwLevel } from '~/server/services/post.service'; -import { - queueComicsForPanelImage, - updateComicNsfwLevelsForImage, - updateModel3DNsfwLevelForThumbnailImage, -} from '~/server/services/nsfwLevels.service'; -import { getImagesModRules, queueImageSearchIndexUpdate } from '~/server/services/image.service'; -import { signalClient } from '~/utils/signal-client'; -import { addImageToQueue } from '~/server/services/games/new-order.service'; -import { getFeatureFlagsLazy } from '~/server/services/feature-flags.service'; import { fanOutArticleImageUpdates } from '~/server/utils/webhook-debounce'; import { logToAxiom } from '~/server/logging/client'; import { recordImageScan } from '~/server/services/scanner-audit.service'; -import { evaluateRules } from '~/server/utils/mod-rules'; -import { createNotification } from '~/server/services/notification.service'; import { removeImageScanJobQueue } from '~/server/services/job-queue.service'; -import { decreaseDate } from '~/utils/date-helpers'; -import { withRetries } from '~/utils/errorHandling'; +import { + readJobFailureReason, + sendIngestionSignal, + logPerceptualHashMatch, + extractFailedSteps, + markImageScanError, + loadImageForScan, + buildAndInsertScanTags, + resolveScanOutcome, + applyIngestionSideEffects, + type ScanImage, +} from '~/server/services/image-scan-pipeline'; export async function isExemptFromAiVerification( imageId: number, @@ -110,107 +64,9 @@ type RepeatStep = { }; export type ScanResultStep = WdTaggingStep | MediaRatingStep | MediaHashStep | RepeatStep; -type NormalizedTag = { - name: string; - confidence: number; - source: TagSource; -}; - -type TagWithId = { id: number; name: string; nsfwLevel: number; type: TagType }; -type ProcessedTag = { - source: TagSource; - confidence: number; - id: number; - name: string; - nsfwLevel: number; - type: TagType; -}; - -// TTL for a stashed job reason. Comfortably longer than the orchestrator's 10-min -// workflow expiry so the terminal workflow event can still read it. -const JOB_REASON_TTL_SECONDS = 15 * 60; - -const jobReasonKey = (workflowId: string) => - `${REDIS_SYS_KEYS.WEBHOOKS.IMAGE_SCAN_JOB_REASON}:${workflowId}` as RedisKeyTemplateSys; - -/** Orchestrator job-level event shape we care about (a subset of WorkflowStepJobEvent). */ -type OrchestratorJobEvent = { - $type?: string; - workflowId?: string; - jobId?: string; - reason?: string | null; -}; - -// Stash the job's failure `reason` keyed by workflowId. No DB/orchestrator round-trip: -// just a short-lived Redis write so the terminal workflow event can classify. -// -// Correlation contract: job events fire before the terminal workflow event, so the -// reason is stashed by the time the workflow event reads it. Across a workflow's -// several jobs this is last-write-wins (fine β€” image scans typically have one failing -// job; the reason strings we classify on are equivalent). If the reason is missing -// (race, or a job event that never arrived) the failure classifies as Unknown, which -// retries conservatively under the bounded cap β€” safe by construction. The TTL bounds -// a stash that never gets a following workflow event so it can't linger. -async function captureJobFailureReason(event: OrchestratorJobEvent) { - const workflowId = event.workflowId; - const reason = typeof event.reason === 'string' ? event.reason.trim() : ''; - if (!workflowId || !reason) return; - await sysRedis - .set(jobReasonKey(workflowId), reason, { EX: JOB_REASON_TTL_SECONDS }) - .catch(() => null); -} - -async function readJobFailureReason(workflowId: string): Promise { - const reason = await sysRedis.get(jobReasonKey(workflowId)).catch(() => null); - if (reason) sysRedis.del(jobReasonKey(workflowId)).catch(() => null); - return reason ?? null; -} - -export async function processImageScanResult(req: NextApiRequest) { - const event: WorkflowEvent = req.body; - - // A job-level failure event only carries the reason β€” capture it and return; the - // workflow-level terminal event (below) does the actual ingestion update. Job - // events carry a top-level `jobId` (workflow events never do), and we only - // subscribe to job:failed/expired/canceled, so any job event here is a failure. - // Cast rather than a type predicate so `event` stays a WorkflowEvent afterwards. - const jobEvent = event as OrchestratorJobEvent; - if (typeof jobEvent.jobId === 'string') { - await captureJobFailureReason(jobEvent); - return; - } - - const { data } = await getWorkflow({ - client: internalOrchestratorClient, - path: { workflowId: event.workflowId }, - }); - if (!data) throw new Error(`could not find workflow: ${event.workflowId}`); - - const imageId = data.metadata?.imageId as number | undefined; - if (!imageId) throw new Error(`missing workflow metadata.imageId - ${event.workflowId}`); - - const featureFlags = getFeatureFlagsLazy({ req }); - await processImageScanWorkflow({ - workflowId: event.workflowId, - status: event.status, - steps: (data.steps ?? []) as unknown as ScanResultStep[], - imageId, - articleImageScanning: featureFlags.articleImageScanning, - startedAt: data.startedAt, - completedAt: data.completedAt, - }); -} - /** - * Core image scan processing extracted so it can be called from both the webhook - * handler (via `processImageScanResult`) and migration scripts that use `wait` - * to get results inline. - * - * The flow reads top-to-bottom as a pipeline: parse the workflow steps, persist - * the perceptual hash / hard-block, load the image, write its tags, resolve the - * ingestion outcome (audit + moderation rules + the row UPDATE), then fire the - * post-update side effects (audit log, article fan-out, cache/index updates, - * realtime signal). Each phase is a single-responsibility helper below. + * Scan result processing for wdTagging + mediaRating workflows. Its stages are shared with + * the imageScanning handler in `image-scan-pipeline`. */ export async function processImageScanWorkflow({ workflowId, @@ -227,9 +83,7 @@ export async function processImageScanWorkflow({ imageId: number; /** Enable debounced article ingestion updates (webhook path with feature flag) */ articleImageScanning?: boolean; - /** Workflow timing from the orchestrator response. Used by recordImageScan - * for the scanner_label_results audit log. Optional so migration scripts - * can still call this without supplying them. */ + /** Workflow timing for the scanner_label_results audit log. */ startedAt?: Date | string | null; completedAt?: Date | string | null; }) { @@ -350,7 +204,7 @@ export async function processImageScanWorkflow({ outcome = await resolveScanOutcome({ image, - mediaRating, + blocked: mediaRating.isBlocked ? { reason: mediaRating.blockedReason ?? null } : undefined, pHash, workflowId, prompt, @@ -463,46 +317,6 @@ export async function processImageScanWorkflow({ }); } -/** - * Push the resolved ingestion state to the uploader's open editor. The editor renders - * Pending as an in-progress spinner, so any state that LEAVES Pending has to send β€” - * Error included, retryable though it is β€” or the card goes on claiming to analyze - * until the page is reloaded. - */ -async function sendIngestionSignal({ - imageId, - userId, - ingestion, - blockedFor, -}: { - imageId: number; - userId: number | null; - ingestion: ImageIngestionStatus; - blockedFor?: string | null; -}) { - if (!userId) return; - await signalClient - .send({ - target: SignalMessages.ImageIngestionStatus, - data: { imageId, ingestion, blockedFor }, - userId, - }) - .catch((error) => - logToAxiom( - { - name: 'image-scan-result', - type: 'warning', - message: `signal send failed: ${ - error instanceof Error ? error.message : 'Unknown error' - }`, - imageId, - source: 'image-scan-result.service', - }, - 'webhooks' - ).catch(() => null) - ); -} - // Step parsing // -------------------------------------------------- function parseScanSteps({ steps, workflowId }: { steps: ScanResultStep[]; workflowId: string }) { @@ -615,59 +429,6 @@ function aggregateMediaRatingRepeater(steps: ScanResultStep[]) { ); } -// Blocked-content detection -// -------------------------------------------------- -async function getIsImageBlocked(hash: bigint) { - if (!env.BLOCKED_IMAGE_HASH_CHECK || !clickhouse) return false; - - const client = clickhouse; - // $query has no retry of its own, and the observed failure is a dropped socket. - const rows = await withRetries( - () => client.$query<{ count: number }>` - SELECT cast(count() as int) as count - FROM blocked_images - WHERE bitCount(bitXor(hash, ${hash})) < 5 AND disabled = false - `, - 2, - 250 - ); - - return (rows?.[0]?.count ?? 0) > 0; -} - -async function logPerceptualHashMatch({ imageId, pHash }: { imageId: number; pHash: bigint }) { - // Nothing branches on this result, so an exhausted retry must not fail the webhook and - // discard a scan that already produced tags and a rating. - const pHashBlocked = await getIsImageBlocked(pHash).catch((error) => { - logToAxiom( - { - name: 'image-phash-match', - type: 'warning', - message: 'pHash blocklist check failed', - imageId, - error: error instanceof Error ? error.message : 'Unknown error', - source: 'image-scan-result.service', - }, - 'webhooks' - ).catch(() => null); - return false; - }); - if (!pHashBlocked) return; - - // blockedReason = 'Similar to blocked content'; - logToAxiom( - { - name: 'image-phash-match', - type: 'info', - message: 'Image pHash matched a blocked image', - imageId, - pHash: pHash.toString(), - source: 'image-scan-result.service', - }, - 'webhooks' - ).catch(() => null); -} - async function blockImageFromRating({ imageId, pHash, @@ -687,742 +448,3 @@ async function blockImageFromRating({ }, }); } - -/** - * Which orchestrator steps reported `failed`, by step name (falling back to - * `$type`). This is the only per-failure diagnostic we get β€” the workflow status - * itself is just `failed`/`canceled` with no reason β€” so it's what tells a - * tagging failure (`tags`/wdTagging) apart from a hash (`hash`/mediaHash) or - * rating (`rating`/mediaRating) failure. Reads `status` off the raw workflow - * steps (not modeled on `ScanResultStep`, which only carries outputs). - */ -function extractFailedSteps(steps: ScanResultStep[]): string[] { - return (steps as Array<{ name?: string; $type?: string; status?: string }>) - .filter((step) => step?.status === 'failed') - .map((step) => step.name ?? step.$type ?? 'unknown'); -} - -/** - * Flip an image to `Error`, increment its scan `retryCount`, and stamp a small - * `scanJobs.error = { status, failureType, failedSteps, reason, failureClass, at }` - * so a plain Postgres query can tell WHY a scan errored (and which step) without an - * orchestrator lookup β€” and so the `ingest-images` cron can pick a retry ceiling - * from `failureClass`. retryCount ALWAYS increments: it's the absolute attempt - * count the per-class ceilings are applied against. Returns the new (post-increment) - * retryCount, the image's mediaType, and the computed failureClass; retryCount / - * mediaType are `null` when no row matched (e.g. the image was deleted between scan - * request and callback). - */ -async function markImageScanError({ - workflowId, - imageId, - status, - failureType, - failedSteps, - reason, - middleware, -}: { - workflowId: string; - imageId: number; - status: string; - failureType: string; - failedSteps: string[]; - /** Human failure reason from the job-level callback, if captured. */ - reason?: string | null; - /** Orchestrator middleware locus, if known. Not exposed on the v2 job event today. */ - middleware?: string | null; -}): Promise<{ - retryCount: number | null; - mediaType: string | null; - userId: number | null; - failureClass: string; -}> { - const failureClass = classifyImageScanFailure({ reason, failureType, middleware, failedSteps }); - // undefined keys are dropped by JSON.stringify, so absent reason/middleware - // simply don't appear in the stored blob. - const errorJson = JSON.stringify({ - status, - failureType, - failedSteps, - reason: reason ?? undefined, - middleware: middleware ?? undefined, - failureClass, - at: new Date().toISOString(), - }); - const rows = await dbWrite.$queryRaw< - { retryCount: number | null; mediaType: string | null; userId: number | null }[] - >` - UPDATE "Image" - SET - "ingestion" = ${ImageIngestionStatus.Error}::"ImageIngestionStatus", - "scanJobs" = jsonb_set( - jsonb_set( - jsonb_set( - COALESCE("scanJobs", '{}'), - '{retryCount}', - to_jsonb(COALESCE(("scanJobs"->>'retryCount')::int, 0) + 1) - ), - '{workflowId}', - ${JSON.stringify(workflowId)}::jsonb - ), - '{error}', - ${errorJson}::jsonb - ) - WHERE id = ${imageId} - RETURNING ("scanJobs"->>'retryCount')::int as "retryCount", type as "mediaType", - "userId" - `; - return { - retryCount: rows[0]?.retryCount ?? null, - mediaType: rows[0]?.mediaType ?? null, - userId: rows[0]?.userId ?? null, - failureClass, - }; -} - -// Image loading -// -------------------------------------------------- -async function loadImageForScan(imageId: number) { - const image = await dbWrite.image.findUnique({ - where: { id: imageId }, - select: { - id: true, - createdAt: true, - scannedAt: true, - type: true, - userId: true, - meta: true, - metadata: true, - postId: true, - nsfwLevelLocked: true, - nsfwLevel: true, - ingestion: true, - }, - }); - - if (!image) throw new Error(`image not found: ${imageId}`); - return image; -} -type ScanImage = Awaited>; - -// Tagging -// -------------------------------------------------- -async function buildAndInsertScanTags({ - imageId, - wdTags, - ratingLevel, - prompt, -}: { - imageId: number; - wdTags: Record; - ratingLevel: string; - prompt?: string; -}) { - const tagsWithSource = { - [TagSource.WD14]: wdTags, - [TagSource.SpineRating]: { [ratingLevel]: 100 }, - }; - const normalizedTags: NormalizedTag[] = Object.entries(tagsWithSource).flatMap( - ([source, tagMap]) => - Object.entries(tagMap).map(([name, confidence]) => { - if (source === TagSource.WD14) name = name.replace(/_/g, ' '); - return { - name, - confidence: Math.round(confidence * 100), - source: source as TagSource, - }; - }) - ); - - const tags = await processTags({ tags: normalizedTags, prompt }); - - await insertTagsOnImageNew( - tags.map((tag) => ({ - imageId, - tagId: tag.id, - source: tag.source, - confidence: tag.confidence, - automated: true, - })) - ); -} - -async function processTags({ - tags: normalized, - prompt, -}: { - tags: NormalizedTag[]; - prompt?: string; -}): Promise { - if (prompt) { - // Detect real person in prompt. Strips the moderator-managed benign phrases first, as - // the audit path does β€” without it a whitelisted proper noun is still written as a - // confidence-100 tag, which reaches the image search index. This is the orchestrator - // path; `getTagsFromIncomingTags` in the webhook is the legacy twin of this function - // and needs the same treatment. - const realPersonName = includesPoi( - // Normalized first, as both audit paths do. Stripping the raw text while the audit that - // runs next reads the normalized copy means two different alphabets decide what counts - // as whitelisted for the same prompt. - await stripBenignPhrases(normalizeText(prompt), BlocklistType.PromptBenignPhrase) - ); - if (realPersonName) { - const tagName = - typeof realPersonName === 'object' ? realPersonName.matchedText : realPersonName; - normalized.push({ - name: tagName.toLowerCase(), - confidence: 100, - source: TagSource.Computed, - }); - } - - // Detect tags from prompt - const promptTags = getTagsFromPrompt(prompt); - if (promptTags) - normalized.push( - ...promptTags.map((name) => ({ name, confidence: 70, source: TagSource.Computed })) - ); - } - - // add computed tags - const computedTags = getComputedTags( - normalized.map((x) => x.name), - 'WD14' - ); - normalized.push( - ...computedTags.map((name) => ({ name, confidence: 70, source: TagSource.Computed })) - ); - - // apply tag rules - const tagRules = await getTagRules(); - for (const rule of tagRules) { - const match = normalized.find((x) => x.name === rule.toTag); - if (!match) continue; - - if (rule.type === 'Replace') { - match.name = rule.fromTag; - } else if (rule.type === 'Append') { - normalized.push({ name: rule.fromTag, confidence: 70, source: TagSource.Computed }); - } - } - - // De-dupe incoming tags and keep tag with highest confidence - const tagMap: Record = {}; - for (const tag of normalized) { - if (!tagMap[tag.name] || tagMap[tag.name].confidence < tag.confidence) tagMap[tag.name] = tag; - } - const deduped: NormalizedTag[] = Object.values(tagMap); - - const { found, missing } = await tagCacheByName.fetch(deduped.map((x) => x.name)); - let queriedTags: TagWithId[] = []; - if (missing.length > 0) { - queriedTags = await dbWrite.tag.findMany({ - where: { name: { in: missing } }, - select: { id: true, name: true, nsfwLevel: true, type: true }, - }); - await tagCacheByName.setMany(queriedTags.map((data) => ({ key: data.name, data }))); - } - const queriedNames = new Set(queriedTags.map((t) => t.name)); - const tagsToCreate = missing.filter((name) => !queriedNames.has(name)); - - let createdTags: TagWithId[] = []; - if (tagsToCreate.length > 0) { - const tagsToInsert = deduped.filter((x) => tagsToCreate.includes(x.name)); - - // Raw SQL bypasses Prisma's @updatedAt stamp, and neither column has a DB - // default β€” both are NOT NULL, so omitting either raises 23502. - const now = new Date(); - const values = tagsToInsert.map( - (tag) => Prisma.sql`(${tag.name}, ${now}, ARRAY['Image']::"TagTarget"[])` - ); - - createdTags = await dbWrite.$queryRaw` - INSERT INTO "Tag" (name, "updatedAt", target) - VALUES ${Prisma.join(values)} - ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name - RETURNING id, name, "nsfwLevel", type - `; - await tagCacheByName.setMany(createdTags.map((data) => ({ key: data.name, data }))); - } - - const allTags = [...found.values(), ...queriedTags, ...createdTags] - .map((tag) => { - const match = normalized.find((x) => x.name === tag.name); - if (!match) return null; - return { ...tag, source: match.source, confidence: match.confidence }; - }) - .filter(isDefined); - - return allTags; -} - -// Outcome resolution (audit + moderation rules + persist) -// -------------------------------------------------- -type ScanOutcome = { - ingestion: ImageIngestionStatus; - blockedFor: string | null; - reviewKey: string | null; - /** Present only when the image was audited (i.e. not the isBlocked short-circuit). */ - audit?: Awaited>; -}; - -/** - * Resolve and persist the final state of the image row, returning the bits the - * post-update side effects need. Three terminal shapes: - * - `isBlocked` (orchestrator content rating): keep the block written by - * `blockImageFromRating`, only stamp provenance β€” do NOT re-audit (that would - * recompute a fresh nsfwLevel/ingestion and silently un-block the image). A - * *prior* block is intentionally NOT sticky: a moderator rescan re-runs the - * workflow with ingestion still 'Blocked' (see `ingestImage`), so letting the - * audit run is what lets a rescan clear a block; hard violations re-block via - * `isBlocked` anyway. - * - `audit.blockedFor` (prompt TOS/CSAM): block. - * - otherwise: Scanned, with moderation rules applied last so a rule Block/Hold - * takes precedence over the audit decision. - */ -async function resolveScanOutcome({ - image, - mediaRating, - pHash, - workflowId, - prompt, - negativePrompt, -}: { - image: ScanImage; - mediaRating: MediaRatingOutput; - pHash?: bigint; - workflowId: string; - prompt?: string; - negativePrompt?: string; -}): Promise { - const updatedAt = new Date(); - - if (mediaRating.isBlocked) { - await dbWrite.$executeRaw` - UPDATE "Image" - SET - "updatedAt" = ${updatedAt}, - "pHash" = COALESCE(${pHash ?? null}, "pHash"), - "scanJobs" = jsonb_set(COALESCE("scanJobs", '{}'), '{workflowId}', ${JSON.stringify( - workflowId - )}::jsonb) - WHERE id = ${image.id} - `; - return { - ingestion: ImageIngestionStatus.Blocked, - blockedFor: mediaRating.blockedReason ?? null, - reviewKey: null, - }; - } - - const audit = await auditScanResults({ - imageId: image.id, - userId: image.userId, - prompt, - negativePrompt, - }); - let reviewKey = audit.reviewKey ?? null; - - const toUpdate: Prisma.ImageUpdateInput = { updatedAt, pHash }; - // AI-generation verification is no longer a blocking gate (per operations - // 2026-05-11): nsfw images that we couldn't auto-verify as AI used to - // land in `Blocked + AiNotVerified`, but the false-positive rate didn't - // justify the friction. The remaining `audit.blockedFor` branch still - // catches hard violations (TOS / Moderated / CSAM) β€” everything else - // falls through to Scanned. - if (audit.blockedFor) { - toUpdate.ingestion = ImageIngestionStatus.Blocked; - toUpdate.blockedFor = audit.blockedFor; - toUpdate.nsfwLevel = NsfwLevel.Blocked; - } else { - toUpdate.ingestion = ImageIngestionStatus.Scanned; - toUpdate.needsReview = reviewKey; - toUpdate.minor = audit.minor; - toUpdate.poi = audit.poi; - toUpdate.blockedFor = null; - // Respect a manually-locked nsfw level β€” never overwrite it from the scan. - toUpdate.nsfwLevel = image.nsfwLevelLocked ? image.nsfwLevel : audit.nsfwLevel; - - // scannedAt reassignment: always stamp the first scan; afterwards only - // re-stamp recent (<1 week old) non-Rescan images that haven't opted out - // via metadata.skipScannedAtReassignment. Older/rescanned images keep - // their original scannedAt. - const now = new Date(); - if (!image.scannedAt) toUpdate.scannedAt = now; - else if ( - !(image.metadata as any)?.skipScannedAtReassignment && - image.ingestion !== 'Rescan' && - new Date(image.createdAt).getTime() >= decreaseDate(now, 7, 'days').getTime() - ) - toUpdate.scannedAt = now; - else toUpdate.scannedAt = image.scannedAt; - } - - // Moderation rules can block the image, hold it for review, or annotate its - // metadata with the matched rule. Applied after the scan audit so a Block/Hold - // takes precedence over the audit's own decision. - const modRule = await evaluateImageModRules(image, audit.tags); - let metadataUpdate: Record | undefined; - if (modRule) { - metadataUpdate = modRule.metadata; - if (modRule.ingestion) toUpdate.ingestion = modRule.ingestion; - if (modRule.nsfwLevel != null) toUpdate.nsfwLevel = modRule.nsfwLevel; - if (modRule.blockedFor) toUpdate.blockedFor = modRule.blockedFor; - if (modRule.needsReview !== undefined) { - toUpdate.needsReview = modRule.needsReview; - if (typeof modRule.needsReview === 'string') reviewKey = modRule.needsReview; - } - // Notify the user only when a rule auto-blocked the image (not on Hold). - if (modRule.ingestion === ImageIngestionStatus.Blocked) { - await notifyImageAutoBlocked({ - imageId: image.id, - userId: image.userId, - reason: modRule.ruleReason, - }); - } - } - - await dbWrite.$executeRaw` - UPDATE "Image" - SET - "updatedAt" = ${toUpdate.updatedAt}, - "pHash" = ${pHash ?? null}, - "ingestion" = ${toUpdate.ingestion as string}::"ImageIngestionStatus", - "blockedFor" = ${(toUpdate.blockedFor as string) ?? null}, - "nsfwLevel" = ${toUpdate.nsfwLevel as number}, - "needsReview" = ${(toUpdate.needsReview as string) ?? null}, - "minor" = ${(toUpdate.minor as boolean) ?? false}, - "poi" = ${(toUpdate.poi as boolean) ?? false}, - "scannedAt" = ${(toUpdate.scannedAt as Date) ?? null}, - "metadata" = COALESCE(${ - metadataUpdate ? JSON.stringify(metadataUpdate) : null - }::jsonb, "metadata"), - "scanJobs" = jsonb_set(COALESCE("scanJobs", '{}'), '{workflowId}', ${JSON.stringify( - workflowId - )}::jsonb) - WHERE id = ${image.id} - `; - - return { - ingestion: toUpdate.ingestion as ImageIngestionStatus, - blockedFor: (toUpdate.blockedFor as string) ?? null, - reviewKey, - audit, - }; -} - -async function auditScanResults(args: { - imageId: number; - userId: number; - prompt?: string; - negativePrompt?: string; -}) { - // Moderator-managed benign phrases (proper nouns / technical terms that coincidentally - // contain a detection token) are blanked up front so every downstream check β€” minor, - // poi, blockedFor β€” sees the same cleaned text. A benign phrase is innocent content, so - // it shouldn't feed any detector. - const [prompt, negativePrompt] = await Promise.all([ - stripBenignPhrases(normalizeText(args.prompt), BlocklistType.PromptBenignPhrase), - stripBenignPhrases(normalizeText(args.negativePrompt), BlocklistType.NegativeBenignPhrase), - ]); - const tags = await dbWrite.$queryRaw< - { id: number; name: string; type: TagType; nsfwLevel: number; confidence: number }[] - >` - SELECT t.id, t.name, t."nsfwLevel", toi.confidence - FROM "TagsOnImageDetails" toi - JOIN "Tag" t ON t.id = toi."tagId" - WHERE toi."imageId" = ${args.imageId} AND toi.automated AND NOT toi.disabled - `; - const nsfwLevel = Math.max(...[...tags.map((x) => x.nsfwLevel), 0]); - const nsfw = nsfwLevel > sfwBrowsingLevelsFlag; - const minorTags = tags.filter((tag) => tagsNeedingReview.includes(tag.name.toLowerCase())); - const poiTags = tags.filter((tag) => poiWords.includes(tag.name.toLowerCase())); - const reviewTags = [ - ...tags.filter((tag) => tag.nsfwLevel === NsfwLevel.Blocked), - ...getConditionalTagsForReview(tags, nsfwLevel), - ]; - const adultTags = tags.filter((tag) => tag.name === 'adult'); - const cartoonTags = tags.filter((tag) => styleTags.includes(tag.name)); - - const tagReview = reviewTags.length > 0; - let poiReview = poiTags.length > 0; - let minorReview = - minorTags.length > 0 && adultTags.length === 0 && (cartoonTags.length === 0 || nsfw); - let newUserReview = false; - - const inappropriate = includesInappropriate({ prompt, negativePrompt }, nsfw); - if (inappropriate === 'minor') minorReview = true; - if (inappropriate === 'poi') poiReview = true; - - const associatedEntities = await getAssociatedEntities(args.imageId); - // Associated poi/minor resources only escalate to review when the image is - // nsfw β€” a sfw image with a poi/minor resource is still flagged (below) but - // not queued for moderator review. - if (associatedEntities.poi && nsfw) poiReview = true; - if (associatedEntities.minor && nsfw) minorReview = true; - - if (!minorReview && !poiReview && !tagReview && nsfw) { - newUserReview = await getIsNewUser(args.userId); - } - - const minor = minorTags.length > 0 || !!associatedEntities.minor; - const poi = poiTags.length > 0 || !!associatedEntities.poi || (!!prompt && !!includesPoi(prompt)); - - let reviewKey: string | undefined; - if (poiReview) reviewKey = 'poi'; - else if (minorReview) reviewKey = 'minor'; - else if (tagReview) reviewKey = 'tag'; - else if (newUserReview) reviewKey = 'newUser'; - - let blockedFor: string | undefined; - if (nsfw && prompt) { - const auditResult = auditMetaData({ prompt }, nsfw); - if (!auditResult.success) - blockedFor = auditResult.blockedFor.join(',') ?? 'Failed audit, no explanation'; - } - - return { - tags, - nsfwLevel, - nsfw, - minorTags, - poiTags, - reviewTags, - tagReview, - poiReview, - minorReview, - newUserReview, - minor, - poi, - reviewKey, - blockedFor, - }; -} - -async function getAssociatedEntities(imageId: number) { - const [result] = await dbWrite.$queryRaw< - { poi: boolean; minor: boolean; hasResource: boolean }[] - >` - WITH to_check AS ( - -- Check based on associated resources - SELECT - SUM(IIF(m.poi, 1, 0)) > 0 "poi", - SUM(IIF(m.minor, 1, 0)) > 0 "minor", - true "hasResource" - FROM "ImageResourceNew" ir - JOIN "ModelVersion" mv ON ir."modelVersionId" = mv.id - JOIN "Model" m ON m.id = mv."modelId" - WHERE ir."imageId" = ${imageId} - UNION - -- Check based on associated bounties - SELECT - SUM(IIF(b.poi, 1, 0)) > 0 "poi", - false "minor", - false "hasResource" - FROM "Image" i - JOIN "ImageConnection" ic ON ic."imageId" = i.id - JOIN "Bounty" b ON ic."entityType" = 'Bounty' AND b.id = ic."entityId" - WHERE ic."imageId" = ${imageId} - UNION - -- Check based on associated bounty entries - SELECT - SUM(IIF(b.poi, 1, 0)) > 0 "poi", - false "minor", - false "hasResource" - FROM "Image" i - JOIN "ImageConnection" ic ON ic."imageId" = i.id - JOIN "BountyEntry" be ON ic."entityType" = 'BountyEntry' AND be.id = ic."entityId" - JOIN "Bounty" b ON b.id = be."bountyId" - WHERE ic."imageId" = ${imageId} - ) - SELECT bool_or(poi) "poi", bool_or(minor) "minor", bool_or("hasResource") "hasResource" FROM to_check; - `; - - return result; -} - -async function getIsNewUser(userId: number) { - const [{ isNewUser }] = - (await dbWrite.$queryRaw<{ isNewUser: boolean }[]>` - SELECT is_new_user(CAST(${userId} AS INT)) "isNewUser"; - `) ?? []; - return isNewUser; -} - -// Moderation rules -// -------------------------------------------------- -/** - * Evaluate the image moderation rules and return the row changes they imply - * (block / hold / metadata annotation). Pure of side effects β€” the caller is - * responsible for persisting the changes and, on a Block, calling - * `notifyImageAutoBlocked`. - */ -async function evaluateImageModRules( - image: { meta: Prisma.JsonValue; metadata: Prisma.JsonValue }, - tags: { name: string }[] -) { - const imageModRules = await getImagesModRules(); - if (!imageModRules.length) return; - - const tagNames = tags.map((x) => x.name); - const meta = (image.meta ?? {}) as Prisma.JsonObject; - const appliedRule = evaluateRules(imageModRules, { ...meta, tags: tagNames }); - if (!appliedRule || appliedRule.action === ModerationRuleAction.Approve) return; - - const result: { - metadata: Record; - ruleReason?: string | null; - ingestion?: ImageIngestionStatus; - nsfwLevel?: NsfwLevel; - blockedFor?: string; - needsReview?: string | null; - } = { - metadata: { - ...((image.metadata ?? {}) as Record), - ruleId: appliedRule.id, - ruleReason: appliedRule.reason, - }, - ruleReason: appliedRule.reason, - }; - - if (appliedRule.action === ModerationRuleAction.Block) { - result.ingestion = ImageIngestionStatus.Blocked; - result.nsfwLevel = NsfwLevel.Blocked; - result.blockedFor = BlockedReason.Moderated; - result.needsReview = null; - } else if (appliedRule.action === ModerationRuleAction.Hold) { - result.needsReview = 'modRule'; - } - - return result; -} - -async function notifyImageAutoBlocked({ - imageId, - userId, - reason, -}: { - imageId: number; - userId: number; - reason?: string | null; -}) { - await createNotification({ - category: NotificationCategory.System, - key: `image-block:${imageId}`, - type: 'system-message', - userId, - details: { - message: `One of your images has been blocked due to a moderation rule violation${ - reason ? ` by the following reason: ${reason}` : '' - }. If you believe this is a mistake, you can appeal this decision.`, - url: `/images/${imageId}`, - }, - }).catch((error) => - logToAxiom({ - name: 'image-scan-result', - type: 'error', - message: 'Could not create notification when blocking image', - data: { - imageId, - error: error.message, - cause: error.cause, - stack: error.stack, - }, - }) - ); -} - -// Post-update side effects -// -------------------------------------------------- -async function applyIngestionSideEffects({ - image, - outcome, -}: { - image: ScanImage; - outcome: ScanOutcome; -}) { - // handle blocked image updates - if (outcome.ingestion === ImageIngestionStatus.Blocked) { - await queueImageSearchIndexUpdate({ - ids: [image.id], - action: SearchIndexUpdateQueueAction.Delete, - }); - // A previously-cached Blocked image can still satisfy the showcase query - // filters (needsReview IS NULL, nsfwLevel != 0) so drop it from the showcase. - if (image.postId) await bustCachesForPosts(image.postId); - await updateModel3DNsfwLevelForThumbnailImage({ imageId: image.id, postId: image.postId }); - // If this image belongs to a comic panel, the parent project may - // have been search-indexed under the old (unblocked) state. Re-queue - // it so the next index pass re-evaluates visibility against the - // moderation gates in `comics.search-index.ts:WHERE`. - await queueComicsForPanelImage(image.id); - return; - } - - // handle scanned image updates - if (outcome.ingestion === ImageIngestionStatus.Scanned) { - // Scanning is what makes an already-published image countable. Bust rather - // than refresh: this fires once per image, so a re-query here would be N - // identical counts for an N-image post. - await userImageVideoCountCaches.bust(image.userId); - await tagIdsForImagesCache.refresh(image.id); - if ( - typeof image.metadata === 'object' && - (image.metadata as MediaMetadata | undefined)?.profilePicture - ) { - await deleteUserProfilePictureCache(image.userId); - } - - if (image.postId) { - await updatePostNsfwLevel(image.postId); - // Without this, the showcase cache stays empty until its 24h TTL for any model version whose images hadn't scanned yet on first read. - await bustCachesForPosts(image.postId); - } - await updateModel3DNsfwLevelForThumbnailImage({ imageId: image.id, postId: image.postId }); - await updateComicNsfwLevelsForImage(image.id); - // Refresh the comic project in the search index β€” even on a clean - // Scanned, `needsReview` may have been set, which the index treats - // as a visibility gate. - await queueComicsForPanelImage(image.id); - - await queueImageSearchIndexUpdate({ - ids: [image.id], - action: SearchIndexUpdateQueueAction.Update, - }); - - const { audit, reviewKey } = outcome; - if (audit) { - const tagsForReview = [...audit.poiTags, ...audit.minorTags, ...audit.reviewTags]; - // Only persist review-tags when the image is actually queued for review - // (matches the legacy `if (reviewKey)` gate). - if (reviewKey && tagsForReview.length > 0) { - await createImageTagsForReview({ - imageId: image.id, - tagIds: tagsForReview.map((x) => x.id), - }); - } - - if (!reviewKey && image.type === 'image') { - await addToNewOrderQueue({ imageId: image.id, nsfw: audit.nsfw }); - } - } - } -} - -const KONO_NSFW_SAMPLING_RATE = 0.3; // 30% -async function addToNewOrderQueue({ imageId, nsfw }: { imageId: number; nsfw: boolean }) { - let shouldAddToQueue = true; - let priority: 1 | 2 | 3 = 1; - const rankType = NewOrderRankType.Knight; - if (nsfw) { - priority = 2; - shouldAddToQueue = Math.random() < KONO_NSFW_SAMPLING_RATE; - } - if (shouldAddToQueue) { - await addImageToQueue({ - imageIds: [imageId], - rankType, - priority, - }); - } -} diff --git a/src/server/services/image-scanner-flag.ts b/src/server/services/image-scanner-flag.ts deleted file mode 100644 index bfca49bb7e..0000000000 --- a/src/server/services/image-scanner-flag.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Pure decision helper for the `image-scanner-new` sysRedis kill-switch. - * - * Lives in its own tiny module so the Buffer-coercion + seed-only-on-unset - * behavior is unit-testable without importing the ~7.9K-line image.service - * (which drags in Prisma/env/auth at load time and can't load under vitest). - * - * sysRedis.get is typed `string` but the HA/Sentinel client returns a Buffer - * for BLOB_STRING replies, matching none of the four literals β†’ pre-fix - * `isImageScannerNewEnabled` fell through and DESTRUCTIVELY overwrote the - * operator's '1' with 'false' (then returned false). Coerce once before - * comparing. See PR #2697/#2700 for the canonical Buffer-vs-string regression. - * - * Returns: - * - `true` for '1' / 'true' - * - `false` for '0' / 'false' - * - `null` for a genuinely-unset/unknown key β€” the ONLY case in which the - * caller should default-seed 'false'. - */ -export function parseScannerFlag(raw: unknown): boolean | null { - const value = Buffer.isBuffer(raw) ? raw.toString('utf8') : raw; - if (value === '1' || value === 'true') return true; - if (value === '0' || value === 'false') return false; - return null; -} diff --git a/src/server/services/image-scanning-result.service.ts b/src/server/services/image-scanning-result.service.ts new file mode 100644 index 0000000000..e4ee88c918 --- /dev/null +++ b/src/server/services/image-scanning-result.service.ts @@ -0,0 +1,310 @@ +import type { + ImageScanningJointAgeDetection, + ImageScanningOutput, + NsfwLevel, +} from '@civitai/orchestration-client'; +import { orchestratorNsfwLevelMap } from '~/shared/constants/browsingLevel.constants'; +import { logToAxiom } from '~/server/logging/client'; +import { + applyIngestionSideEffects, + buildAndInsertScanTags, + extractFailedSteps, + loadImageForScan, + logPerceptualHashMatch, + markImageScanError, + readJobFailureReason, + resolveScanOutcome, + sendIngestionSignal, + type ScanLog, +} from '~/server/services/image-scan-pipeline'; +import { removeImageScanJobQueue } from '~/server/services/job-queue.service'; +import { computePerceptualHash } from '~/server/services/orchestrator/orchestrator.service'; +import { recordImageScanningResult } from '~/server/services/scanner-audit.service'; +import { fanOutArticleImageUpdates } from '~/server/utils/webhook-debounce'; +import { ImageIngestionStatus } from '~/shared/utils/prisma/enums'; + +type RawRepeatStep = { + $type?: string; + input?: { template?: { $type?: string } }; + output?: { steps?: Array<{ $type?: string }> }; +}; + +/** Whether a workflow was submitted with imageScanning, whatever state its steps ended in. */ +export function isImageScanningWorkflow(steps: unknown[]) { + return (steps as RawRepeatStep[]).some( + (step) => + step.$type === 'imageScanning' || + (step.$type === 'repeat' && + (step.input?.template?.$type === 'imageScanning' || + step.output?.steps?.[0]?.$type === 'imageScanning')) + ); +} + +type Recognition = { label: string; score: number }; + +/** One image's scan, or a video's frames combined into one. */ +export type ImageScanningResult = { + nsfwLevel: NsfwLevel; + tags: Record; + csam: boolean | null; + ageDetections: ImageScanningJointAgeDetection[]; + minorDetected: boolean; + aiRecognition?: Recognition; + animeRecognition?: Recognition; + perceptualHash?: string; +}; + +type RawStep = { $type?: string; name?: string; status?: string; output?: unknown }; + +// wdTagging only ever returned general tags; the other categories would write tags no image had before. +const INGESTED_TAG_CATEGORIES = new Set(['general']); + +function readScan(output: ImageScanningOutput, workflowId: string): ImageScanningResult { + const { tagging, jointAgeClassification } = output; + // An unmapped level ('na', or one added upstream) would write a rating with no NSFW level. + // Not "invalid media…": classifyImageScanFailure reads that as permanent, and this is recoverable. + if (orchestratorNsfwLevelMap[output.nsfwLevel] === undefined) + throw new Error(`media rating unavailable (${output.nsfwLevel}) for workflow: ${workflowId}`); + if (!tagging?.ran) + throw new Error(`Incomplete workflow: ${workflowId}. Tagging did not run (${tagging?.status})`); + + const tags: Record = {}; + for (const { tag, category, score } of tagging.tags) { + if (INGESTED_TAG_CATEGORIES.has(category)) tags[tag] = Math.max(score, tags[tag] ?? 0); + } + + return { + nsfwLevel: output.nsfwLevel, + tags, + csam: output.csam ?? null, + ageDetections: jointAgeClassification?.detections ?? [], + minorDetected: !!jointAgeClassification?.minorDetected, + aiRecognition: output.aiRecognition, + animeRecognition: output.animeRecognition, + }; +} + +const higher = (a?: Recognition, b?: Recognition) => (!a || (b && b.score > a.score) ? b : a); + +// Every result is kept (a max or an OR per field), so a frame can only raise what the video reports. +function combineFrames(frames: ImageScanningResult[]): ImageScanningResult { + return frames.reduce((acc, frame) => { + const tags = { ...acc.tags }; + for (const [tag, score] of Object.entries(frame.tags)) + tags[tag] = Math.max(score, tags[tag] ?? 0); + return { + nsfwLevel: + orchestratorNsfwLevelMap[frame.nsfwLevel] > orchestratorNsfwLevelMap[acc.nsfwLevel] + ? frame.nsfwLevel + : acc.nsfwLevel, + tags, + csam: acc.csam || frame.csam ? true : acc.csam ?? frame.csam, + ageDetections: [...acc.ageDetections, ...frame.ageDetections], + minorDetected: acc.minorDetected || frame.minorDetected, + aiRecognition: higher(acc.aiRecognition, frame.aiRecognition), + animeRecognition: higher(acc.animeRecognition, frame.animeRecognition), + }; + }); +} + +/** + * Reads a succeeded imageScanning ingestion workflow: one `imageScanning` step for an image, + * or a `repeat` of them over extracted frames for a video, plus the `mediaHash` step. + * Throws when the workflow carries nothing usable. + */ +export function parseImageScanningSteps(workflowSteps: unknown[], workflowId: string) { + const steps = workflowSteps as RawStep[]; + const perceptualHash = ( + steps.find((step) => step.$type === 'mediaHash')?.output as + | { hashes?: { perceptual?: string } } + | undefined + )?.hashes?.perceptual; + + const single = steps.find((step) => step.$type === 'imageScanning')?.output; + if (single) return { ...readScan(single as ImageScanningOutput, workflowId), perceptualHash }; + + const frames = ( + steps.find( + (step) => + step.$type === 'repeat' && + (step.output as { steps?: RawStep[] } | undefined)?.steps?.[0]?.$type === 'imageScanning' + )?.output as { steps: RawStep[] } | undefined + )?.steps; + if (!frames?.length || !frames.every((frame) => frame.output)) + throw new Error(`Incomplete workflow: ${workflowId}. Missing imageScanning output`); + + return { + ...combineFrames( + frames.map((frame) => readScan(frame.output as ImageScanningOutput, workflowId)) + ), + perceptualHash, + }; +} + +export const IMAGE_SCANNING_LOG: ScanLog = { + name: 'image-scanning-result', + source: 'image-scanning-result.service', +}; + +type ScanErrorInput = Parameters[0]; + +async function failScan( + input: ScanErrorInput & { + articleImageScanning: boolean; + logType: 'warning' | 'error'; + message: string; + stack?: string; + } +) { + const { articleImageScanning, logType, message, stack, ...error } = input; + const { retryCount, mediaType, userId, failureClass } = await markImageScanError(error); + logToAxiom( + { + name: IMAGE_SCANNING_LOG.name, + type: logType, + message, + source: IMAGE_SCANNING_LOG.source, + stack, + ...error, + failureClass, + mediaType, + retryCount, + }, + 'webhooks' + ).catch(() => null); + await sendIngestionSignal({ + imageId: error.imageId, + userId, + ingestion: ImageIngestionStatus.Error, + log: IMAGE_SCANNING_LOG, + }); + if (articleImageScanning) await fanOutArticleImageUpdates(error.imageId); +} + +/** Core image scan processing for imageScanning workflows. */ +export async function processImageScanningWorkflow({ + workflowId, + status, + steps, + imageId, + articleImageScanning = false, + startedAt, + completedAt, +}: { + workflowId: string; + status: string; + steps: unknown[]; + imageId: number; + articleImageScanning?: boolean; + startedAt?: Date | string | null; + completedAt?: Date | string | null; +}) { + if (status !== 'succeeded') { + await failScan({ + workflowId, + imageId, + status, + failureType: + status === 'expired' ? 'expired' : status === 'canceled' ? 'canceled' : 'workflow-failed', + failedSteps: extractFailedSteps(steps), + reason: await readJobFailureReason(workflowId), + articleImageScanning, + logType: 'warning', + message: `workflow not succeeded: ${status}`, + }); + return; + } + + let scan: ReturnType; + try { + scan = parseImageScanningSteps(steps, workflowId); + } catch (error) { + await failScan({ + workflowId, + imageId, + status, + failureType: 'unusable-result', + failedSteps: extractFailedSteps(steps), + reason: error instanceof Error ? error.message : 'Unknown error', + articleImageScanning, + logType: 'warning', + message: 'succeeded workflow had no usable result', + }); + return; + } + + let image: Awaited>; + let outcome: Awaited>; + try { + const pHash = computePerceptualHash(scan.perceptualHash); + if (pHash) await logPerceptualHashMatch({ imageId, pHash, log: IMAGE_SCANNING_LOG }); + + image = await loadImageForScan(imageId); + const { prompt, negativePrompt } = (image.meta ?? {}) as { + prompt?: string; + negativePrompt?: string; + }; + await buildAndInsertScanTags({ + imageId, + wdTags: scan.tags, + ratingLevel: scan.nsfwLevel, + prompt, + }); + outcome = await resolveScanOutcome({ + image, + pHash, + workflowId, + prompt, + negativePrompt, + log: IMAGE_SCANNING_LOG, + }); + } catch (error) { + // Deleted between submit and callback β€” no row to mark. The route ACKs it. + if (error instanceof Error && error.message.startsWith('image not found')) throw error; + await failScan({ + workflowId, + imageId, + status, + failureType: 'processing-failed', + failedSteps: extractFailedSteps(steps), + reason: error instanceof Error ? error.message : 'Unknown error', + articleImageScanning, + logType: 'error', + message: 'failed to process a succeeded workflow', + stack: error instanceof Error ? error.stack : undefined, + }); + return; + } + + // The verdict is persisted; throwing now would 400 a finished webhook and make the + // orchestrator re-run it. + try { + await removeImageScanJobQueue([imageId]); + await recordImageScanningResult({ workflowId, imageId, scan, startedAt, completedAt }); + if (articleImageScanning) await fanOutArticleImageUpdates(imageId); + await applyIngestionSideEffects({ image, outcome }); + } catch (error) { + logToAxiom( + { + name: IMAGE_SCANNING_LOG.name, + type: 'error', + message: 'side effects failed after the scan verdict was persisted', + source: IMAGE_SCANNING_LOG.source, + stack: error instanceof Error ? error.stack : undefined, + reason: error instanceof Error ? error.message : 'Unknown error', + imageId, + workflowId, + ingestion: outcome.ingestion, + }, + 'webhooks' + ).catch(() => null); + } + + await sendIngestionSignal({ + imageId, + userId: image.userId, + ingestion: outcome.ingestion, + blockedFor: outcome.blockedFor, + log: IMAGE_SCANNING_LOG, + }); +} diff --git a/src/server/services/image.service.ts b/src/server/services/image.service.ts index 2e76cb8c94..8733f65f87 100644 --- a/src/server/services/image.service.ts +++ b/src/server/services/image.service.ts @@ -37,7 +37,6 @@ import { import { imageReviewedSql } from '~/server/common/image-visibility'; import { BlockedReason, - ImageScanType, ImageSort, NotificationCategory, NsfwLevel, @@ -140,11 +139,7 @@ import type { } from '~/server/schema/image.schema'; import { imageMetaOutput, ingestImageSchema } from '~/server/schema/image.schema'; import type { ImageMetadata, VideoMetadata } from '~/server/schema/media.schema'; -import { - articlesSearchIndex, - imagesMetricsSearchIndex, - imagesSearchIndex, -} from '~/server/search-index'; +import { imagesMetricsSearchIndex, imagesSearchIndex } from '~/server/search-index'; import type { ImageMetricsSearchIndexRecord, MetricsImageFilterableAttribute, @@ -172,7 +167,6 @@ import { } from '~/server/services/model3d.service'; import { addImageToQueue } from '~/server/services/games/new-order.service'; import { upsertImageFlag } from '~/server/services/image-flag.service'; -import { parseScannerFlag } from '~/server/services/image-scanner-flag'; import { deleteImagTagsForReviewByImageIds, getImagTagsForReviewByImageIds, @@ -203,7 +197,6 @@ import { throwInternalServerError, throwNotFoundError, } from '~/server/utils/errorHandling'; -import { fetchTimeoutSignal } from '~/server/utils/fetch-timeout'; import type { RuleDefinition } from '~/server/utils/mod-rules'; import { getCursor } from '~/server/utils/pagination-helpers'; import { @@ -267,7 +260,10 @@ import type { } from '../../../event-engine-common/types/package-stubs'; import type { FeedQueryInput } from '../../../event-engine-common/feeds/types'; import type { ImageQueryInput } from '../../../event-engine-common/types/image-feed-types'; -import { createImageIngestionRequest } from '~/server/services/orchestrator/orchestrator.service'; +import { + createImageIngestionRequest, + imageIngestionLogName, +} from '~/server/services/orchestrator/orchestrator.service'; import { getGenerationDisplayKeys } from '~/server/services/orchestrator/legacy-metadata-mapper'; import { sanitizeProvenance, @@ -1154,29 +1150,6 @@ export const getImageById = async ({ id }: GetByIdInput) => { }); }; -/** - * Runtime toggle for the new image ingestion path (createImageIngestionRequest - * with the expanded mediaRating step). Reads from Redis so ops can flip - * without a deploy. Accepts '1'/'true' / '0'/'false' as string values. - * - * If the key doesn't exist (first request after deploy), seeds it to 'false' - * so the toggle is discoverable in Redis and explicitly off by default. - * Operators set the key to '1' to enable. - */ -async function isImageScannerNewEnabled(): Promise { - // The HA/Sentinel sysRedis returns a Buffer for BLOB_STRING replies, which - // matched none of the literals pre-fix β†’ fell through and destructively - // overwrote the operator's '1' with 'false'. parseScannerFlag coerces the - // Buffer first and returns null ONLY for a genuinely-unset/unknown key, so - // the seed below now fires only in its intended default-seeding case. - // See PR #2697/#2700 for the canonical Buffer-vs-string regression. - const raw = await sysRedis.get(REDIS_SYS_KEYS.SYSTEM.IMAGE_SCANNER_NEW); - const parsed = parseScannerFlag(raw); - if (parsed !== null) return parsed; - await sysRedis.set(REDIS_SYS_KEYS.SYSTEM.IMAGE_SCANNER_NEW, 'false'); - return false; -} - export const ingestImageById = async ({ id }: GetByIdInput) => { const images = await dbWrite.$queryRaw` SELECT id, url, type, width, height, meta->>'prompt' as prompt @@ -1198,16 +1171,6 @@ export const ingestImageById = async ({ id }: GetByIdInput) => { return await ingestImage({ image: images[0] }); }; -// const scanner = env.EXTERNAL_IMAGE_SCANNER; -// const clavataScan = env.CLAVATA_SCAN; -export const imageScanTypes: ImageScanType[] = [ - ImageScanType.WD14, - // ImageScanType.Hash, - // ImageScanType.Clavata, - // ImageScanType.Hive, - ImageScanType.SpineRating, -]; - function extractSubmitErrorMessage(error: unknown): string | null { if (!error) return null; if (typeof error === 'string') return error; @@ -1292,233 +1255,64 @@ export const ingestImage = async ({ const scanRequestedAt = new Date(); const dbClient = tx ?? dbWrite; - // if (!isProd || !env.IMAGE_SCANNING_ENDPOINT) { - // console.log('skipping image ingestion'); - // const updated = await dbClient.image.update({ - // where: { id: image.id }, - // select: { postId: true }, - // data: { - // scanRequestedAt, - // scannedAt: scanRequestedAt, - // ingestion: ImageIngestionStatus.Scanned, - // nsfwLevel: NsfwLevel.PG, - // }, - // }); - - // // Update post NSFW level - // if (updated.postId) await updatePostNsfwLevel(updated.postId); - - // return true; - // } - const parsedImage = ingestImageSchema.safeParse(image); if (!parsedImage.success) throw new Error('Failed to parse image data'); - const { url, id, type, width, height } = parsedImage.data; + const { url, id, type } = parsedImage.data; const callbackUrl = env.IMAGE_SCANNING_CALLBACK ?? `${env.NEXTAUTH_URL}/api/webhooks/image-scan-result?token=${env.WEBHOOK_TOKEN}`; - if (!image.prompt) { - const { prompt } = await dbClient.$queryRaw<{ prompt?: string }>` - SELECT meta->>'prompt' as prompt FROM "Image" WHERE id = ${id} - `; - image.prompt = prompt; - } - - if (await isImageScannerNewEnabled()) { - const { - data: workflowResponse, - error: submitError, - status: submitStatus, - } = await createImageIngestionRequest({ - imageId: id, - url, - type, - callbackUrl, - priority: lowPriority ? 'low' : undefined, - }); - if (!workflowResponse) { - imageScanSubmittedCounter.inc({ lane: 'new', result: 'failed' }); - const failureClass = await markImageScanSubmitFailure({ - dbClient, - imageId: id, - status: submitStatus, - error: submitError, - }); - // The orchestrator submit already logs the transient failure in - // createImageIngestionRequest, but from here it's otherwise a silent - // `return false` β€” surface it at the dispatch layer so the failure is - // attributable to a specific image + media type. - logToAxiom({ - name: 'image-ingestion', - type: 'error', - reason: 'no-workflow-response', - failureType: 'send-fail', - failureClass, - responseStatus: submitStatus, - imageId: id, - mediaType: type, - }).catch(() => null); - return false; - } - const scanJobsJson = JSON.stringify({ workflowId: workflowResponse.id }); - await dbClient.$executeRaw` - UPDATE "Image" - SET - "scanRequestedAt" = ${scanRequestedAt}, - "scanJobs" = CASE - WHEN "scanJobs" IS NOT NULL AND "scanJobs" ? 'retryCount' THEN - ${scanJobsJson}::jsonb || jsonb_build_object('retryCount', ("scanJobs"->'retryCount')) - ELSE - ${scanJobsJson}::jsonb - END - WHERE id = ${id} - `; - imageScanSubmittedCounter.inc({ lane: 'new', result: 'success' }); - return true; - } - - let scanUrl = `${env.IMAGE_SCANNING_ENDPOINT}/enqueue`; - if (lowPriority) scanUrl += '?lowpri=true'; - - const response = await fetch(scanUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - signal: fetchTimeoutSignal(60_000), - body: JSON.stringify({ - imageId: id, - imageKey: url, - type, - width, - height, - prompt: image.prompt, - // wait: true, - scans: imageScanTypes, - callbackUrl, - movieRatingModel: env.IMAGE_SCANNING_MODEL, - }), + const { + data: workflowResponse, + error: submitError, + status: submitStatus, + useImageScanning, + } = await createImageIngestionRequest({ + imageId: id, + url, + type, + callbackUrl, + priority: lowPriority ? 'low' : undefined, }); - if (response.status === 202) { - const scanJobs = (await response.json().catch(() => Prisma.JsonNull)) as - | { jobId: string } - | typeof Prisma.JsonNull; - - // Convert scanJobs to JSON string for raw SQL, preserving existing retryCount if it exists - const scanJobsJson = scanJobs === Prisma.JsonNull ? null : JSON.stringify(scanJobs); - - if (scanJobsJson) { - await dbClient.$executeRaw` - UPDATE "Image" - SET - "scanRequestedAt" = ${scanRequestedAt}, - "scanJobs" = CASE - WHEN "scanJobs" IS NOT NULL AND "scanJobs" ? 'retryCount' THEN - ${scanJobsJson}::jsonb || jsonb_build_object('retryCount', ("scanJobs"->'retryCount')) - ELSE - ${scanJobsJson}::jsonb - END - WHERE id = ${id} - `; - } else { - await dbClient.$executeRaw` - UPDATE "Image" - SET "scanRequestedAt" = ${scanRequestedAt} - WHERE id = ${id} - `; - } - - imageScanSubmittedCounter.inc({ lane: 'legacy', result: 'success' }); - return true; - } else { - await logToAxiom({ - name: 'image-ingestion', - type: 'error', + const lane = useImageScanning ? 'imageScanning' : 'new'; + if (!workflowResponse) { + imageScanSubmittedCounter.inc({ lane, result: 'failed' }); + const failureClass = await markImageScanSubmitFailure({ + dbClient, imageId: id, - url, - responseStatus: response.status, + status: submitStatus, + error: submitError, }); - - imageScanSubmittedCounter.inc({ lane: 'legacy', result: 'failed' }); + // createImageIngestionRequest's own log carries neither mediaType nor failureClass. + logToAxiom({ + name: imageIngestionLogName(useImageScanning), + type: 'error', + reason: 'no-workflow-response', + failureType: 'send-fail', + failureClass, + responseStatus: submitStatus, + imageId: id, + mediaType: type, + }).catch(() => null); return false; } -}; - -export const ingestImageBulk = async ({ - images, - tx, - lowPriority = true, - scans, -}: { - images: IngestImageInput[]; - tx?: Prisma.TransactionClient; - lowPriority?: boolean; - scans?: ImageScanType[]; -}): Promise => { - if (!env.IMAGE_SCANNING_ENDPOINT) - throw new Error('missing IMAGE_SCANNING_ENDPOINT environment variable'); - - const callbackUrl = env.IMAGE_SCANNING_CALLBACK; - const scanRequestedAt = new Date(); - const imageIds = images.map(({ id }) => id); - const dbClient = tx ?? dbWrite; - - if (!imageIds.length) return false; - - // TODO.articleImageScan: uncomment when ready to enable image scanning for articles - // if (!isProd || !callbackUrl) { - // console.log('skip ingest'); - // await dbClient.image.updateMany({ - // where: { id: { in: imageIds } }, - // data: { - // scanRequestedAt, - // scannedAt: scanRequestedAt, - // ingestion: ImageIngestionStatus.Scanned, - // nsfwLevel: NsfwLevel.PG, - // }, - // }); - // return true; - // } - - const needsPrompts = !images.some((x) => x.prompt); - if (needsPrompts) { - const prompts = await dbClient.$queryRaw<{ id: number; prompt?: string }[]>` - SELECT id, meta->>'prompt' as prompt FROM "Image" WHERE id IN (${Prisma.join(imageIds)}) + const scanJobsJson = JSON.stringify({ workflowId: workflowResponse.id }); + await dbClient.$executeRaw` + UPDATE "Image" + SET + "scanRequestedAt" = ${scanRequestedAt}, + "scanJobs" = CASE + WHEN "scanJobs" IS NOT NULL AND "scanJobs" ? 'retryCount' THEN + ${scanJobsJson}::jsonb || jsonb_build_object('retryCount', ("scanJobs"->'retryCount')) + ELSE + ${scanJobsJson}::jsonb + END + WHERE id = ${id} `; - const promptMap = Object.fromEntries(prompts.map((x) => [x.id, x.prompt])); - for (const image of images) image.prompt = promptMap[image.id]; - } - - const response = await fetch( - env.IMAGE_SCANNING_ENDPOINT + `/enqueue-bulk?lowpri=${lowPriority}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - signal: fetchTimeoutSignal(60_000), - body: JSON.stringify( - images.map((image) => ({ - imageId: image.id, - imageKey: image.url, - type: image.type, - width: image.width, - height: image.height, - prompt: image.prompt, - scans: scans ?? imageScanTypes, - callbackUrl, - })) - ), - } - ); - if (response.status === 202) { - await dbClient.image.updateMany({ - where: { id: { in: imageIds } }, - data: { scanRequestedAt }, - }); - return true; - } - - return false; + imageScanSubmittedCounter.inc({ lane, result: 'success' }); + return true; }; export function enqueueImageIngestion({ @@ -6967,20 +6761,6 @@ export async function reportCsamImages({ await bulkSetReportStatus({ ids: reportIds, status: ReportStatus.Actioned, userId: user.id, ip }); } -export async function ingestArticleCoverImages(array: { imageId: number; articleId: number }[]) { - const imageIds = array.map((x) => x.imageId); - const images = await dbRead.image.findMany({ - where: { id: { in: imageIds } }, - select: { id: true, url: true, height: true, width: true }, - }); - - await articlesSearchIndex.queueUpdate( - array.map((x) => ({ id: x.articleId, action: SearchIndexUpdateQueueAction.Update })) - ); - - await ingestImageBulk({ images, lowPriority: true }); -} - export async function updateImageNsfwLevel({ id, nsfwLevel, @@ -7282,15 +7062,16 @@ export async function addImageTools({ select: { id: true, url: true, + type: true, }, }); - if (updated.length > 0) { - await ingestImageBulk({ - images: updated, - lowPriority: true, - }); - } + enqueueImageIngestion({ + images: updated, + name: 'add-image-tools', + userId: user.id, + lowPriority: true, + }); for (const { imageId } of data) { purgeImageGenerationDataCache(imageId); diff --git a/src/server/services/orchestrator/__tests__/createModelFileScanRequest.test.ts b/src/server/services/orchestrator/__tests__/createModelFileScanRequest.test.ts index 69f9e25891..a7beb96dce 100644 --- a/src/server/services/orchestrator/__tests__/createModelFileScanRequest.test.ts +++ b/src/server/services/orchestrator/__tests__/createModelFileScanRequest.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type * as FliptClient from '~/server/flipt/client'; const { mockDbWrite, @@ -7,6 +8,7 @@ const { mockStringifyAIR, mockResolveDownloadUrl, mockIsProd, + mockIsFlipt, } = vi.hoisted(() => ({ mockDbWrite: { modelFile: { update: vi.fn() }, @@ -19,6 +21,7 @@ const { // don't regress; failure-path tests reset to mockRejectedValue. mockResolveDownloadUrl: vi.fn().mockResolvedValue({ url: 'https://cdn.example/file' }), mockIsProd: { value: true }, + mockIsFlipt: vi.fn(), })); vi.mock('~/server/db/client', () => ({ dbWrite: mockDbWrite })); @@ -78,6 +81,11 @@ vi.mock('~/env/server', () => ({ }, })); +vi.mock('~/server/flipt/client', async (importOriginal) => ({ + ...(await importOriginal()), + isFlipt: mockIsFlipt, +})); + // orchestrator.service.ts pulls in edge-url which validates // NEXT_PUBLIC_* env vars at import-time. Stub it to keep tests hermetic. vi.mock('~/client-utils/edge-url', () => ({ @@ -85,6 +93,7 @@ vi.mock('~/client-utils/edge-url', () => ({ })); import { + createImageIngestionRequest, createModelFileScanRequest, ModelFileScanSubmissionError, } from '~/server/services/orchestrator/orchestrator.service'; @@ -609,3 +618,130 @@ describe('createModelFileScanRequest', () => { }); }); }); + +describe('createImageIngestionRequest', () => { + const submittedSteps = () => + mockSubmitWorkflow.mock.calls[0][0].body.steps as Array<{ $type: string; input: unknown }>; + + beforeEach(() => { + mockIsFlipt.mockReset().mockResolvedValue(false); + mockSubmitWorkflow.mockResolvedValue({ + data: { id: 'workflow-1' }, + response: { status: 200, headers: new Headers() }, + }); + }); + + it('submits wdTagging + mediaRating while the imageScanning flag is off', async () => { + await createImageIngestionRequest({ imageId: 1, url: 'image-key' }); + expect(mockIsFlipt).toHaveBeenCalledWith('image-ingestion-image-scanning', '1'); + // The legacy submit is the production path; its inputs must stay exactly as they were. + const metadata = { imageId: 1 }; + const priority = 'normal'; + const mediaUrl = { $ref: '$arguments', path: 'mediaUrl' }; + expect(submittedSteps()).toEqual([ + { + $type: 'wdTagging', + name: 'tags', + metadata, + priority, + input: { + mediaUrl, + model: + 'urn:air:siglip2:repository:huggingface:cella110n/cl_tagger_v2@b57909b8e9c63f71e208a26473e7aabdf45ed6b6.tar', + threshold: 0.55, + }, + }, + { + $type: 'mediaRating', + name: 'rating', + metadata, + priority, + input: { + mediaUrl, + engine: 'civitai', + includeAgeClassification: true, + includeAIRecognition: false, + includeFaceRecognition: false, + includeAnimeRecognition: false, + }, + }, + { + $type: 'mediaHash', + name: 'hash', + metadata, + priority, + input: { mediaUrl, hashTypes: ['perceptual'] }, + }, + ]); + }); + + it.each([ + [false, 'image-ingestion'], + [true, 'image-scanning-ingestion'], + ])('logs a failed submit under its pipeline name (flag %s)', async (flagOn, name) => { + vi.useRealTimers(); + mockIsFlipt.mockResolvedValue(flagOn); + mockSubmitWorkflow.mockResolvedValue({ + data: undefined, + error: 'bad request', + response: { status: 400, headers: new Headers() }, + }); + const result = await createImageIngestionRequest({ imageId: 1, url: 'image-key' }); + + expect(result.useImageScanning).toBe(flagOn); + expect(mockLogToAxiom).toHaveBeenCalledWith(expect.objectContaining({ name, imageId: 1 })); + }); + + it('submits one imageScanning step when the flag is on', async () => { + mockIsFlipt.mockResolvedValue(true); + await createImageIngestionRequest({ imageId: 1, url: 'image-key' }); + const steps = submittedSteps(); + expect(steps.map((step) => step.$type)).toEqual(['imageScanning', 'mediaHash']); + expect(steps[0].input).toEqual({ image: { $ref: '$arguments', path: 'mediaUrl' } }); + expect(steps[1].input).toEqual({ + mediaUrl: { $ref: '$arguments', path: 'mediaUrl' }, + hashTypes: ['perceptual'], + }); + }); + + const frameUrl = { $ref: 'frame', path: 'url' }; + it.each([ + [ + false, + [ + ['wdTagging', 'mediaUrl'], + ['mediaRating', 'mediaUrl'], + ], + ], + [true, [['imageScanning', 'image']]], + ])('repeats the per-frame steps over video frames (flag %s)', async (flagOn, templates) => { + mockIsFlipt.mockResolvedValue(flagOn); + await createImageIngestionRequest({ imageId: 1, url: 'video-key', type: 'video' }); + const steps = submittedSteps() as Array<{ + $type: string; + input: { + videoUrl?: unknown; + for?: unknown; + template?: { $type: string; input: Record }; + }; + }>; + + expect(steps[0]).toMatchObject({ + $type: 'videoFrameExtraction', + input: { + videoUrl: { $ref: '$arguments', path: 'mediaUrl' }, + frameRate: 1, + uniqueThreshold: 0.9, + maxFrames: 50, + }, + }); + expect(steps.slice(1)).toHaveLength(templates.length); + steps.slice(1).forEach((step, i) => { + const [type, urlField] = templates[i]; + expect(step.$type).toBe('repeat'); + expect(step.input.for).toEqual({ $ref: 'videoFrames', path: 'output.frames', as: 'frame' }); + expect(step.input.template?.$type).toBe(type); + expect(step.input.template?.input[urlField]).toEqual(frameUrl); + }); + }); +}); diff --git a/src/server/services/orchestrator/orchestrator.service.ts b/src/server/services/orchestrator/orchestrator.service.ts index 0dae26288e..353204679f 100644 --- a/src/server/services/orchestrator/orchestrator.service.ts +++ b/src/server/services/orchestrator/orchestrator.service.ts @@ -18,6 +18,7 @@ import { logToAxiom } from '~/server/logging/client'; import { internalOrchestratorClient } from '~/server/services/orchestrator/client'; import { submitWorkflowWithRetry } from '~/server/services/orchestrator/workflows'; import { hashContent } from '~/server/services/entity-moderation.service'; +import { FLIPT_FEATURE_FLAGS, isFlipt } from '~/server/flipt/client'; import type { MediaType, ModelType } from '~/shared/utils/prisma/enums'; import { EntityModerationStatus, ModelHashType, ScanResultCode } from '~/shared/utils/prisma/enums'; import { stringifyAIR } from '~/shared/utils/air'; @@ -47,6 +48,54 @@ const IMAGE_TAGGING_MODEL = 'urn:air:siglip2:repository:huggingface:cella110n/cl_tagger_v2@b57909b8e9c63f71e208a26473e7aabdf45ed6b6.tar'; const IMAGE_TAGGING_THRESHOLD = 0.55; +/** Axiom `name` for a failed ingestion submit, per scan pipeline. */ +export function imageIngestionLogName(useImageScanning: boolean) { + return useImageScanning ? 'image-scanning-ingestion' : 'image-ingestion'; +} + +type MediaUrlRef = { $ref: string; path: string }; + +function imageScanSteps({ + mediaUrl, + metadata, + priority, + useImageScanning, +}: { + mediaUrl: MediaUrlRef; + metadata: Record; + priority: Priority; + useImageScanning: boolean; +}) { + if (useImageScanning) + return [ + { $type: 'imageScanning', name: 'scan', metadata, priority, input: { image: mediaUrl } }, + ]; + + return [ + { + $type: 'wdTagging', + name: 'tags', + metadata, + priority, + input: { mediaUrl, model: IMAGE_TAGGING_MODEL, threshold: IMAGE_TAGGING_THRESHOLD }, + }, + { + $type: 'mediaRating', + name: 'rating', + metadata, + priority, + input: { + mediaUrl, + engine: 'civitai', + includeAgeClassification: true, + includeAIRecognition: false, + includeFaceRecognition: false, + includeAnimeRecognition: false, + }, + }, + ]; +} + export async function createImageIngestionRequest({ imageId, url, @@ -68,6 +117,45 @@ export async function createImageIngestionRequest({ // server-side, re-submitting with the same `externalId` returns the existing // workflow instead of duplicating it (orchestrator dedupes on (userId, externalId)). const externalId = randomUUID(); + const useImageScanning = await isFlipt( + FLIPT_FEATURE_FLAGS.IMAGE_INGESTION_IMAGE_SCANNING, + String(imageId) + ); + const mediaUrl = { $ref: '$arguments', path: 'mediaUrl' }; + + const steps = + type === 'image' + ? [ + ...imageScanSteps({ mediaUrl, metadata, priority, useImageScanning }), + { + $type: 'mediaHash', + name: 'hash', + metadata, + priority, + input: { mediaUrl, hashTypes: ['perceptual'] }, + }, + ] + : [ + { + $type: 'videoFrameExtraction', + name: 'videoFrames', + metadata, + priority, + input: { videoUrl: mediaUrl, frameRate: 1, uniqueThreshold: 0.9, maxFrames: 50 }, + }, + ...imageScanSteps({ + mediaUrl: { $ref: 'frame', path: 'url' }, + metadata, + priority, + useImageScanning, + }).map((template) => ({ + $type: 'repeat', + input: { + for: { $ref: 'videoFrames', path: 'output.frames', as: 'frame' }, + template, + }, + })), + ]; const body: WorkflowTemplate = { externalId, @@ -76,110 +164,8 @@ export async function createImageIngestionRequest({ mediaUrl: edgeUrl, }, currencies: [], - steps: - type === 'image' - ? [ - { - $type: 'wdTagging', - name: 'tags', - metadata, - priority, - input: { - mediaUrl: { $ref: '$arguments', path: 'mediaUrl' }, - model: IMAGE_TAGGING_MODEL, - threshold: IMAGE_TAGGING_THRESHOLD, - }, - } as WorkflowStepTemplate, - { - $type: 'mediaRating', - name: 'rating', - metadata, - priority, - input: { - mediaUrl: { $ref: '$arguments', path: 'mediaUrl' }, - engine: 'civitai', - includeAgeClassification: true, - includeAIRecognition: false, - includeFaceRecognition: false, - includeAnimeRecognition: false, - }, - } as WorkflowStepTemplate, - { - $type: 'mediaHash', - name: 'hash', - metadata, - priority, - input: { - mediaUrl: { $ref: '$arguments', path: 'mediaUrl' }, - hashTypes: ['perceptual'], - }, - } as WorkflowStepTemplate, - ] - : [ - { - $type: 'videoFrameExtraction', - name: 'videoFrames', - metadata, - priority, - input: { - videoUrl: { $ref: '$arguments', path: 'mediaUrl' }, - frameRate: 1, - uniqueThreshold: 0.9, - maxFrames: 50, - }, - } as WorkflowStepTemplate, - { - $type: 'repeat', - input: { - for: { - $ref: 'videoFrames', - path: 'output.frames', - as: 'frame', - }, - template: { - $type: 'wdTagging', - name: 'tags', - metadata, - priority, - input: { - mediaUrl: { - $ref: 'frame', - path: 'url', - }, - model: IMAGE_TAGGING_MODEL, - threshold: IMAGE_TAGGING_THRESHOLD, - }, - }, - }, - } as WorkflowStepTemplate, - { - $type: 'repeat', - input: { - for: { - $ref: 'videoFrames', - path: 'output.frames', - as: 'frame', - }, - template: { - $type: 'mediaRating', - name: 'rating', - metadata, - priority, - input: { - mediaUrl: { - $ref: 'frame', - path: 'url', - }, - engine: 'civitai', - includeAgeClassification: true, - includeAIRecognition: false, - includeFaceRecognition: false, - includeAnimeRecognition: false, - }, - }, - }, - } as WorkflowStepTemplate, - ], + // WorkflowTemplate is @civitai/client's, which predates the imageScanning step. + steps: steps as unknown as WorkflowStepTemplate[], callbacks: callbackUrl ? [ { @@ -229,7 +215,7 @@ export async function createImageIngestionRequest({ if (!data) { logToAxiom({ type: 'error', - name: 'image-ingestion', + name: imageIngestionLogName(useImageScanning), imageId, url, externalId, @@ -240,7 +226,7 @@ export async function createImageIngestionRequest({ }); } - return { data, body, error, status: response?.status }; + return { data, body, error, status: response?.status, useImageScanning }; } const PERCEPTUAL_HASH_WAIT_SECONDS = 30; diff --git a/src/server/services/orchestrator/promptAuditing.ts b/src/server/services/orchestrator/promptAuditing.ts index c648979603..63db13e76d 100644 --- a/src/server/services/orchestrator/promptAuditing.ts +++ b/src/server/services/orchestrator/promptAuditing.ts @@ -258,7 +258,7 @@ export async function auditPromptServer(options: AuditPromptOptions): Promise 0) { + labels.push({ + ...baseRow, + label: 'minor', + labelValue: scan.ageDetections + .map((d) => d.ageBand) + .filter((x): x is string => !!x) + .join(', '), + score: clamp01(Math.max(...scan.ageDetections.map((d) => d.under18Probability ?? 0))), + triggered: scan.minorDetected ? 1 : 0, + }); + } + + for (const [recognition, labelValue] of [ + [scan.aiRecognition, 'ai_recognition'], + [scan.animeRecognition, 'anime_recognition'], + ] as const) { + if (!recognition?.label) continue; + labels.push({ + ...baseRow, + label: normClassifierLabel(recognition.label), + labelValue, + score: clamp01(recognition.score), + triggered: 1, + }); + } + + await insertRows({ + workflowId, + scanner: 'image_ingestion', + contentHash: computeContentHash(`image:${imageId}`), + entityType: 'image', + entityId: String(imageId), + modelVersion: IMAGE_SCANNING_AUDIT_VERSION, + startedAt, + completedAt, + labels, + }); +} diff --git a/src/server/utils/webhook-debounce.ts b/src/server/utils/webhook-debounce.ts index d335f53a0c..ad70b1d85d 100644 --- a/src/server/utils/webhook-debounce.ts +++ b/src/server/utils/webhook-debounce.ts @@ -94,9 +94,8 @@ export async function debounceArticleUpdate(articleId: number): Promise { * advance article state, and `updateArticleNsfwLevels` picks up the new cover * rating so `Article.nsfwLevel` no longer lags the cover. * - * Callers are responsible for gating on the `articleImageScanning` feature flag - * because the two webhook entrypoints resolve the flag differently (one reads - * from a request context, the other receives it as a parameter). + * Callers gate on the `articleImageScanning` feature flag β€” it needs a request context, + * which this helper does not have. */ export async function fanOutArticleImageUpdates(imageId: number): Promise { const [articleConnections, coverArticles] = await Promise.all([ diff --git a/tests/preview-post-upload.spec.ts b/tests/preview-post-upload.spec.ts index da6a6ed93f..3ef8e12e92 100644 --- a/tests/preview-post-upload.spec.ts +++ b/tests/preview-post-upload.spec.ts @@ -21,9 +21,8 @@ import { trpcMutation, trpcQuery, uniqueToken } from './preview-trpc'; * bucket (a tiny PNG is written each run β€” harmless). * * Scope ceiling (honest): asserts the upload + attach + PUBLISH DB path. It does NOT - * assert public-feed visibility β€” image ingestion/scan is unreachable in preview - * (placeholder IMAGE_SCANNING_ENDPOINT), so the row stays ingestion:Pending. That does - * NOT block row creation, attach, or publish. + * assert public-feed visibility β€” image ingestion never completes in preview, so the row + * stays ingestion:Pending. That does NOT block row creation, attach, or publish. * * Role: tester (free member, passes the gate; guardedProcedure cleared via onboarding=15). */