mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
6471abff78
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) <noreply@anthropic.com>
447 lines
31 KiB
Markdown
447 lines
31 KiB
Markdown
# Article Content Scanning — Production Rollout Rundown
|
||
|
||
**PR**: civitai/civitai#1879 (`feature/scan-article-images` → `environment-swap`)
|
||
**Companion doc**: [`docs/article-content-scanning.md`](./article-content-scanning.md) (full architecture reference, 1000+ lines)
|
||
**Open proposal**: [`docs/article-ingestion-status-proposal.md`](./article-ingestion-status-proposal.md) — architectural gap surfaced during rollout review, see §5.9 below before deploying
|
||
**Scope of this doc**: deploy-time context, gotchas, and the deltas that matter *right now*. For the end-to-end architecture, read the companion doc.
|
||
|
||
> ⚠️ **Read §5.9 before rollout.** A review pass on 2026-04-10 surfaced a real leak window in the current deploy sequence (code ships with `articles: ['public']` before either backfill runs, so Civitai Green sees legacy articles at their pre-scan `nsfwLevel`). The mitigation is either a staged flag flip or a followup `Article.ingestion` refactor — both options are specced out in [`article-ingestion-status-proposal.md`](./article-ingestion-status-proposal.md). Do not merge without picking a path.
|
||
|
||
> 📌 **Deprecation note (2026-04-17)**: `Article.nsfw` is no longer used as a reference anywhere in the app. The field remains in the DB schema but is neither written to nor read for any enforcement decision. Text moderation and Actioned NSFW user reports now enforce an R floor on `nsfwLevel` via `EXISTS` subqueries against `EntityModeration` and `ArticleReport`/`Report` inside `updateArticleNsfwLevels`. Because the floor is computed from persisted records rather than a flag on the Article row, it survives later image rescans (durable) *and* re-derives to the ground-truth level when those records disappear (e.g. a mod un-actions a report). Image-driven downgrades still work: `nsfwLevel = GREATEST(userNsfwLevel, image-derived level, moderation floor)`. References to `article.nsfw = true` below describe the historical behavior at the time this PR shipped.
|
||
|
||
---
|
||
|
||
## 1. TL;DR — what this PR does
|
||
|
||
Ships the full "articles get scanned like everything else" system and turns it on for Civitai Green:
|
||
|
||
1. **Image extraction** — every `<img>` / `<edge-media>` inside article HTML is walked out of the Tiptap AST, persisted as `Image` + `ImageConnection` rows, and queued for the standard ingestion pipeline (WD14 / Hive / Clavata / Hash). Cover images already went through this; content images now do too.
|
||
2. **Text moderation via xGuard** — on every article create/update, `(title + stripped content)` is fire-and-forget submitted to the orchestrator xGuard workflow with `labels: ['nsfw']`. A content-hash on `EntityModeration` dedupes unchanged text.
|
||
3. **Composite NSFW level** — `updateArticleNsfwLevels` computes `GREATEST(userNsfwLevel, image-derived level, moderation floor)`, where the moderation floor is an `EXISTS` subquery against `EntityModeration` (text-mod Succeeded + NSFW-or-blocked) and `ArticleReport`/`Report` (Actioned NSFW reports) that returns R when either is present, else 0. Because the floor is re-evaluated on every recompute, a text-flagged article lands at R minimum regardless of image ratings, and the floor drops automatically if a mod invalidates the underlying record. (Previously this was gated on `a.nsfw = TRUE` forcing the full `nsfwBrowsingLevelsFlag` mask — see deprecation note above.)
|
||
4. **Serving-path hygiene for Civitai Green** — closed every read path that previously ignored `nsfwLevel` on articles (`getCivitaiNews`, `getCivitaiEvents`, `getArticleById`, `sitemap-articles.xml`, `/api/og`) so articles can be served to the `public` audience safely.
|
||
5. **Profanity filter retired from articles** — orchestrator text moderation supersedes the lexical profanity keyword filter. `ArticleMetadata.profanityMatches`/`profanityEvaluation` fields were removed; the legacy `migrate-profanity-nsfw.ts` admin backfill no longer accepts `entity=articles` (models + bounties still use it).
|
||
6. **Backfill endpoints** — `migrate-article-images.ts` backfills image links, `migrate-article-text-moderation.ts` backfills xGuard submissions. Both are cancellable on client disconnect.
|
||
|
||
**Feature flag state on this branch**:
|
||
|
||
| Flag | Value | Effect |
|
||
|---|---|---|
|
||
| `articles` | `['public']` | Articles are served to Civitai Green (was `['blue','red','public']`) |
|
||
| `articleCreate` | `['public']` | Creation open to all |
|
||
| `articleImageScanning` | `['public']` | Image-scan pipeline + webhook debounce active for everyone |
|
||
|
||
---
|
||
|
||
## 2. What changed *since* the companion doc was written (2026-04-07)
|
||
|
||
The companion doc is comprehensive but pre-dates the last four commits on the branch. If you only read that doc, you will miss these:
|
||
|
||
### 2.1 Handler dispatch for text moderation was just re-enabled (commit `7d47f8546`)
|
||
|
||
**This is the most important rollout line item.**
|
||
|
||
Before this commit, `src/pages/api/webhooks/text-moderation-result.ts:111-124` had the entity-handler dispatch commented out behind a `// TODO: Re-enable once we've validated EntityModeration data` block. That meant:
|
||
|
||
- `EntityModeration` rows were being recorded (`status: Succeeded`, `blocked`, `triggeredLabels`, `result` JSON all populated)
|
||
- **But no downstream enforcement ran**: `article.nsfw` was never flipped, `updateArticleNsfwLevels` was never called from the webhook, and `UnpublishedViolation` auto-unpublish never fired
|
||
|
||
After deploying this branch:
|
||
|
||
- Every incoming xGuard success event invokes the `Article` handler in `entityHandlers`
|
||
- On any `blocked || labels includes 'nsfw'`, the article gets `nsfw: true` + recomputed `nsfwLevel`
|
||
- On `blocked === true`, the article's `status` flips to `UnpublishedViolation` and the owner receives a `system-message` notification with key `article-text-blocked-<id>`
|
||
|
||
**Implication**: if production was already receiving xGuard callbacks pre-deploy (e.g. during staged merge testing), those `EntityModeration` rows are recorded but their enforcement never ran. Re-running the text-moderation backfill post-deploy will re-submit articles whose status is not `Succeeded` — but **articles that already have a successful `EntityModeration` will NOT be re-enforced** because the backfill's `WHERE em.status != 'Succeeded'` clause skips them. See §5.2 for the mitigation.
|
||
|
||
### 2.2 Text moderation simplified to `['nsfw']`-only labels (commit `0403abecb`)
|
||
|
||
The original design mapped individual xGuard labels (`sexual`, `hate`, `violence`, etc.) to specific `NsfwLevel` values via `mapTriggeredLabelsToNsfwLevel`. That's gone. Now:
|
||
|
||
- Article create/update, the retry job, and the backfill all submit with `labels: ['nsfw']`
|
||
- The webhook `Article` handler treats the result as a binary — `blocked || triggeredLabels.includes('nsfw')` → `article.nsfw = true`
|
||
- **Elevation only**: never downgrades. An article flipped NSFW by text can't be un-NSFW'd by text moderation (only by moderator action or image-side recalculation).
|
||
|
||
The companion doc's "Label-to-NsfwLevel mapping" table (lines 358–372) is **stale** and should be ignored for articles. Models/bounties/etc. may still use the old path.
|
||
|
||
### 2.3 NSFW aggregation changed from `bit_or` → `GREATEST(max(...))` (commit `28b38c7dd`)
|
||
|
||
`updateArticleNsfwLevels` in `src/server/services/nsfwLevels.service.ts:280` used to `bit_or` the cover image level with a `bit_or` of all content image levels. That was a correctness bug: `NsfwLevel` values are powers of two (PG=1, PG13=2, R=4, X=8, XXX=16), so `bit_or`ing a PG (1) image with an R (4) image produced `5`, and `5 & publicBrowsingLevelsFlag (1)` is non-zero, so the article leaked through public filters.
|
||
|
||
The fix uses `GREATEST(max(cover), max(content))` — integer max over powers-of-two == highest rating. Single-bit output, no multi-bit leaks.
|
||
|
||
**Implication for deploy**: running `updateArticleNsfwLevels` over legacy articles (which happens implicitly when backfills complete) will **change existing masks** for any article that mixed PG + NSFW content images under the old code. Watch the articles Meilisearch index for re-index churn during the backfill. This is expected.
|
||
|
||
### 2.4 Durable moderation floor via subqueries
|
||
|
||
> 🔁 Rewritten 2026-04-17 as part of the `Article.nsfw` deprecation. Original CASE-WHEN design preserved below for historical reference.
|
||
|
||
The UPDATE at `nsfwLevels.service.ts` now computes the level from three ground-truth sources — image ratings, user-set level, and a moderation floor derived on-the-fly from persisted records:
|
||
|
||
```sql
|
||
WITH level AS (
|
||
-- image-derived max (same as before)
|
||
...
|
||
),
|
||
moderation_floor AS (
|
||
SELECT a.id,
|
||
CASE
|
||
WHEN EXISTS (
|
||
SELECT 1 FROM "EntityModeration" em
|
||
WHERE em."entityType" = 'Article' AND em."entityId" = a.id
|
||
AND em.status = 'Succeeded'
|
||
AND (em.blocked = TRUE OR 'nsfw' = ANY(em."triggeredLabels"))
|
||
) OR EXISTS (
|
||
SELECT 1 FROM "ArticleReport" ar
|
||
JOIN "Report" r ON r.id = ar."reportId"
|
||
WHERE ar."articleId" = a.id
|
||
AND r.reason = 'NSFW' AND r.status = 'Actioned'
|
||
) THEN 4 -- NsfwLevel.R
|
||
ELSE 0
|
||
END AS "floor"
|
||
...
|
||
)
|
||
UPDATE "Article" a
|
||
SET "nsfwLevel" = GREATEST(a."userNsfwLevel", level."nsfwLevel", mf."floor")
|
||
FROM level JOIN moderation_floor mf ON mf.id = level.id
|
||
WHERE level.id = a.id
|
||
AND GREATEST(a."userNsfwLevel", level."nsfwLevel", mf."floor") != a."nsfwLevel"
|
||
```
|
||
|
||
Why this shape:
|
||
|
||
- **Durable**: text-mod floor survives arbitrary later image rescans, because the `EntityModeration` row is the source of truth and gets re-read every time.
|
||
- **Reversible**: if a mod un-actions an NSFW report or manually invalidates a text-mod record, the next recompute re-derives `nsfwLevel` from user + image ground truth — no sticky state to clean up.
|
||
- **Image-responsive**: replacing NSFW content with SFW content genuinely downgrades `nsfwLevel` (the old ELSE branch's behavior, preserved). User manual downgrade attempts are still blocked upstream by the `upsertArticle` auto-clamp `data.userNsfwLevel = Math.max(data.userNsfwLevel, article.nsfwLevel)` for non-mods.
|
||
- **Text-mod-only-flagged articles with PG images** land at `R` (NsfwLevel.R = 4) only — not the full `R|X|XXX|Blocked` mask the old `CASE WHEN` produced. This is the bug fix that motivated this PR.
|
||
- The `Article.nsfw` boolean is no longer written or read.
|
||
|
||
**Historical behavior (deprecated 2026-04-17):** the update was gated on `a.nsfw = TRUE`, setting `nsfwLevel = nsfwBrowsingLevelsFlag` (R|X|XXX|Blocked = 60). The text-mod webhook wrote `nsfw: true` before calling `updateArticleNsfwLevels([id])`. The NSFW-report path in `report.service.ts` did the same. Both are gone; the subqueries replace them.
|
||
|
||
### 2.5 Articles feature flag opened to `['public']`
|
||
|
||
Civitai Green now serves articles. This is **intentionally bundled** with the read-path hygiene fixes in commit `28b38c7dd` — they ship together or not at all. Do not ship one without the other.
|
||
|
||
### 2.6 `migrate-profanity-nsfw.ts` dropped the articles entity (commit `81e3efbc2`)
|
||
|
||
Not a rollout concern, but noted for completeness: because `ArticleMetadata.profanityMatches`/`profanityEvaluation` were removed, the articles branch of the legacy profanity admin backfill failed typecheck. The entire articles branch was removed. Models and bounties still work.
|
||
|
||
---
|
||
|
||
## 3. Pre-rollout checklist
|
||
|
||
Before merging and deploying:
|
||
|
||
- [ ] **`TEXT_MODERATION_CALLBACK` env var is set in production** (`src/env/server-schema.ts:97` — optional in schema, required at runtime). xGuard needs to know where to POST results. Without it, `EntityModeration` rows will pile up in `Pending` forever.
|
||
- [ ] **Redis is reachable** — webhook debounce (`src/server/utils/webhook-debounce.ts`, 5-second window) uses Redis. Without Redis, debounce degrades to per-webhook DB updates — functional but expensive under scan burst.
|
||
- [ ] **Orchestrator client is healthy** — `internalOrchestratorClient` is used by both submission and `getWorkflow` in the webhook. If it's unreachable, submissions will log to Axiom (`article-text-moderation`) and webhook processing will 400.
|
||
- [ ] **Feature flag review** — confirm `articles: ['public']`, `articleCreate: ['public']`, `articleImageScanning: ['public']` in `feature-flags.service.ts` are the intended values for this cut. All three are set on this branch.
|
||
- [ ] **`retry-failed-text-moderation` job is scheduled** — cron `*/15 * * * *`, defined in `src/server/jobs/text-moderation-retry.ts`. Verify it's registered in the job runner. Picks up Failed/Expired/Canceled workflows after 60 min, stuck Pending after 30 min, retry cap 9.
|
||
- [ ] **Axiom dataset contains expected streams**: `article-text-moderation`, `article-image-linking`, `text-moderation-result`, `article-image-scan`.
|
||
- [ ] **Staging dry-run of both backfills** — at minimum run each with `dryRun=true` and confirm the logged counts look sane before flipping `dryRun=false`.
|
||
|
||
---
|
||
|
||
## 4. Rollout sequence
|
||
|
||
This is the order to run on production. Each step is individually rollback-able; don't batch them.
|
||
|
||
### Step 1 — Deploy the code
|
||
|
||
Ship the branch. With the feature flags already at `['public']`, this turns on:
|
||
|
||
- Image extraction + scan queue on every new/edited article
|
||
- xGuard submission on every new/edited article
|
||
- Webhook enforcement (handler dispatch is now live)
|
||
- The new `updateArticleNsfwLevels` math
|
||
- Civitai Green serving of articles
|
||
|
||
**Expected immediate behavior**:
|
||
|
||
- New articles created after deploy will go `Draft → Processing → Published` (image pipeline gates Processing→Published; text moderation runs in parallel, non-gating).
|
||
- New article text is submitted to xGuard within the upsert tRPC call, fire-and-forget.
|
||
|
||
### Step 2 — Backfill image links (idempotent, resumable)
|
||
|
||
Populates `ImageConnection` rows and recomputes `nsfwLevel` for every existing article:
|
||
|
||
```bash
|
||
# Start with dry-run at low concurrency
|
||
curl "https://civitai.com/api/admin/temp/migrate-article-images?dryRun=true&concurrency=2"
|
||
|
||
# Then the real run
|
||
curl "https://civitai.com/api/admin/temp/migrate-article-images?dryRun=false&concurrency=2"
|
||
```
|
||
|
||
- **Mode is now image-scan only.** The `mode=` parameter described in the companion doc (§Deployment) was split off in commit `0403abecb`. Text moderation is handled by a separate endpoint (§Step 3).
|
||
- Cancelable: closing the HTTP request cancels in-flight `pgDbRead` queries cleanly.
|
||
- Watch Axiom (`article-image-scan` / `article-image-linking`) for errors.
|
||
- Expect legacy articles' `nsfwLevel` to change (see §2.3). This is correct.
|
||
|
||
### Step 3 — Backfill text moderation
|
||
|
||
Submits every Published article without a `Succeeded` `EntityModeration` row to xGuard:
|
||
|
||
```bash
|
||
# Dry-run first
|
||
curl "https://civitai.com/api/admin/temp/migrate-article-text-moderation?dryRun=true&concurrency=5"
|
||
|
||
# Then the real run (priority: low so it doesn't starve synchronous submissions)
|
||
curl "https://civitai.com/api/admin/temp/migrate-article-text-moderation?dryRun=false&concurrency=5"
|
||
```
|
||
|
||
- Endpoint: `src/pages/api/admin/temp/migrate-article-text-moderation.ts`
|
||
- `concurrency` is capped at 5 (schema), `batchSize` at 1000.
|
||
- Batch-by-batch cursor walk with `pgDbRead.cancellableQuery`. Safe to interrupt and resume (restart with `?start=<lastCursor>`).
|
||
- Skips articles with `content = ''` and articles already `Succeeded`.
|
||
- **Does not** re-submit articles that already have a `Succeeded` `EntityModeration` — see §5.2 if you need to force re-enforcement.
|
||
- Backfill submissions use `priority: 'low'` to avoid starving the synchronous submission queue from new article saves.
|
||
|
||
### Step 4 — Monitor for 24h
|
||
|
||
See §6 for the monitoring queries.
|
||
|
||
---
|
||
|
||
## 5. Gotchas and things to keep in mind
|
||
|
||
### 5.1 Auto-unpublish is now user-visible
|
||
|
||
With the webhook handler re-enabled, a blocked text moderation result flips `status = UnpublishedViolation` and sends a `system-message` notification to the author:
|
||
|
||
> "Your article was unpublished because its content violates our Terms of Service."
|
||
|
||
**False positives are possible and visible.** If xGuard returns `blocked: true` incorrectly, the article owner gets notified. Moderators can restore via `restoreArticleById`. Watch the first 24h closely for notification bursts — especially since the backfill will submit every historical article at once.
|
||
|
||
**Consider**: running the text-moderation backfill with a narrow date range first (`?after=2026-04-01`) to bound the blast radius, then expand.
|
||
|
||
### 5.2 Articles with stale `Succeeded` EntityModeration will NOT be re-enforced by the backfill
|
||
|
||
If any xGuard callbacks hit production *before* this deploy while the handler was stubbed out, those articles now have:
|
||
- `EntityModeration.status = Succeeded`
|
||
- `EntityModeration.blocked = true` (in some cases)
|
||
- But `Article.nsfw = false` and `Article.status = Published`
|
||
|
||
The backfill's `WHERE em.status != 'Succeeded'` clause skips these. To remediate, run this one-off query (read first, then decide):
|
||
|
||
```sql
|
||
-- Find previously-blocked articles that were never enforced
|
||
SELECT em."entityId", a.title, a.status, a.nsfw, em."updatedAt"
|
||
FROM "EntityModeration" em
|
||
JOIN "Article" a ON a.id = em."entityId"
|
||
WHERE em."entityType" = 'Article'
|
||
AND em.status = 'Succeeded'
|
||
AND em.blocked = true
|
||
AND a.status != 'UnpublishedViolation';
|
||
```
|
||
|
||
If the list is small and the `blocked` verdicts are trustworthy, enforce manually via moderator tools or a targeted script. Do **not** just flip statuses blindly — at minimum reconfirm the `triggeredLabels` and `result` JSON first.
|
||
|
||
### 5.3 Content hash dedupe will block re-moderation
|
||
|
||
`submitTextModeration` computes SHA-256 over `(title + stripped content)` and compares against `EntityModeration.contentHash`. If unchanged, the submission is a no-op. This means:
|
||
|
||
- Editing only the cover image or tags → no re-submission (correct, desired)
|
||
- xGuard recalibration → existing articles won't be re-scored **even if you resubmit**
|
||
|
||
If you need to force re-moderation across the corpus (e.g., after a classifier update), you must `UPDATE "EntityModeration" SET "contentHash" = NULL, status = 'Pending' WHERE "entityType" = 'Article'` before running the backfill. Coordinate with the orchestrator team before doing this — it will fan out a large submission burst.
|
||
|
||
### 5.4 Webhook debounce hides per-image observability
|
||
|
||
`debounceArticleUpdate` coalesces up to 50 image webhooks → 1 article update over a 5-second window. You will **not** see per-image entries in the article update audit trail, only the coalesced aggregate. When debugging a specific image, query `Image.ingestion`/`Image.nsfwLevel` directly rather than trying to reconstruct from the article's update history.
|
||
|
||
### 5.5 nsfwLevel mask shape change is a one-way migration
|
||
|
||
Old articles that mixed PG and NSFW content images have multi-bit masks like `5` (PG | R). The new `GREATEST(max())` math produces single-bit masks. Once the backfill rewrites these rows, the old masks are gone. There is no "rollback nsfwLevel calculation" path without restoring from a DB snapshot — the commit is non-reversible *for data*. Code can still be reverted; the data cannot.
|
||
|
||
### 5.6 Tiptap extraction walks both JSON and HTML
|
||
|
||
`getContentMedia` at `src/server/services/article-content-cleanup.service.ts` tries Tiptap JSON first (content starts with `{`), otherwise parses HTML via `@tiptap/html/server`'s `generateJSON`. The client-side `extractImagesFromArticle` uses browser `DOMParser`. If the two ever drift, the UI scan-status hook and the server-side linker will disagree. Spot-check one known-good article after deploy.
|
||
|
||
### 5.7 `Processing` is a new enum value, but it's owner/mod-only — `status === 'Published'` filters are correct
|
||
|
||
`ArticleStatus.Processing` is **new to this branch** (introduced in `prisma/schema.full.prisma` on `feature/scan-article-images`; it doesn't exist on main). Articles land in `Processing` when they have `Pending` content images that haven't finished scanning yet.
|
||
|
||
**Visibility**: `Processing` behaves like a hidden state from the public's perspective. Confirmed gating:
|
||
|
||
- `getArticles` (`src/server/services/article.service.ts:257`) — non-owner, non-mod callers get `a.status = 'Published'` hard-filtered into the SQL WHERE clause.
|
||
- `getArticleById` (`src/server/services/article.service.ts:639-641`) — non-mod callers see an article only if `status = Published` **or** they're the owner; otherwise 404.
|
||
- `articles.search-index.ts:175` — Meilisearch pull is scoped to `status = Published`, so Processing articles are not indexed or searchable.
|
||
- Feeds, sitemap, OG cards, `getCivitaiNews`, `getCivitaiEvents` all flow through the same `getArticles`-style gates.
|
||
|
||
**Therefore**: downstream code that filters `status === 'Published'` is **correct and intended**. Do not "audit" it. The contract is: Processing is an internal transient state, owner + mods only, eventually becomes Published (or stays Processing indefinitely if scans genuinely fail, which is a separate operational concern covered in §6).
|
||
|
||
**The real gotchas around Processing** (keep these in mind):
|
||
|
||
1. **Re-scan temporarily hides already-published articles from the public.** When an author edits a previously-Published article and the edit introduces new unscanned images, the upsert path flips status back to `Processing` while preserving `publishedAt` (`article.service.ts:953`). During the scan window, the article is invisible to everyone except the author and mods, even though its `publishedAt` is unchanged. Authors may report "my article disappeared after I edited it" — this is working as designed. The `ArticleScanStatus` component in the upsert form surfaces the state to the author.
|
||
|
||
2. **New enum value propagation.** Anything that mirrors the `ArticleStatus` enum outside the main Prisma schema (replicas, Metabase/ClickHouse ETL, analytics dashboards, TypeScript-generated clients consumed by other services, external OpenAPI specs) will see a value it doesn't recognize. Check before deploy:
|
||
- ClickHouse/Metabase article status breakdowns
|
||
- Any consumer of the REST/tRPC article endpoints that casts to its own enum
|
||
- Moderator tooling that lists articles by status — does it render `Processing` gracefully?
|
||
|
||
3. **Articles stuck in `Processing` are invisible silent failures.** Because they don't appear in public feeds, a scan pipeline outage manifests as authors reporting "my article won't publish" rather than as public-facing errors. Watch the stuck-Processing query in §6 closely during the first 24h.
|
||
|
||
### 5.8 Profanity filter removal is irreversible-in-this-branch
|
||
|
||
`ArticleMetadata.profanityMatches` and `profanityEvaluation` are gone from the type. Any code in `environment-swap` or `main` that read these fields will fail typecheck post-merge. Grep for references before merging:
|
||
|
||
```bash
|
||
git grep -E "profanityMatches|profanityEvaluation" -- 'src/**/*.ts' 'src/**/*.tsx'
|
||
```
|
||
|
||
As of this branch, only `migrate-profanity-nsfw.ts` (models + bounties sections) references them.
|
||
|
||
### 5.9 ⚠️ Legacy articles are exposed to Civitai Green before backfills run
|
||
|
||
**This is the most important gotcha on the page. It was missed on the first review pass and surfaced on 2026-04-10.** Don't deploy this branch until a decision has been made on how to handle it. Full analysis and proposed fix: [`article-ingestion-status-proposal.md`](./article-ingestion-status-proposal.md).
|
||
|
||
**The gap**: the current rollout sequence (§4) deploys code with `articles: ['public']` **before** either backfill runs. At that moment, every legacy article becomes visible to Civitai Green at its pre-scan `nsfwLevel` — which was computed from cover image + `userNsfwLevel` only, because `ImageConnection` rows for content images don't exist yet and `article.nsfw` from text moderation hasn't been set. So a legacy article with a PG cover, NSFW content images embedded in the body, and NSFW prose is currently classified `nsfwLevel = PG` and will be happily served to Green for the entire duration of the backfill window.
|
||
|
||
§2.3 of this doc already acknowledges that "running `updateArticleNsfwLevels` over legacy articles will change existing masks for any article that mixed PG + NSFW content images under the old code." Every article whose mask **changes** during the backfill was being served at the wrong level **before** the backfill ran. That's the leak.
|
||
|
||
**Two paths to fix it**, both specced out in [`article-ingestion-status-proposal.md`](./article-ingestion-status-proposal.md):
|
||
|
||
1. **Path A — Staged flag flip (operational, no code changes).** Ship this branch with `articles: ['blue','red','public']` (pre-branch state). Deploy, run both backfills to completion, then flip `articles: ['public']` in a followup deploy. Cheap. Requires two deploys and a manual wait-for-backfill step. Followup ticket: implement the `Article.ingestion` refactor (Path B) so the next article rollout doesn't have this problem.
|
||
|
||
2. **Path B — `Article.ingestion` refactor (architectural).** Add an `ArticleIngestionStatus` enum + `Article.ingestion` column modeled after `Image.ingestion`. Serving paths filter on `ingestion = 'Scanned'` for public/Green viewers. Legacy migration defaults everything to `Pending` → every legacy article is immediately invisible to Green. Backfills flip articles to `Scanned` one at a time as both image and text scans complete. Self-gating, no staged flag flip needed. Bigger PR surface area (several hundred lines + schema migration + serving gates across ~6 read sites). Also closes the live async text-scan window from the 2026-04-10 decision without the UX cost that killed Option A.
|
||
|
||
**Decision criteria**: run this against prod before picking a path —
|
||
|
||
```sql
|
||
SELECT
|
||
COUNT(*) AS total_articles,
|
||
COUNT(*) FILTER (WHERE status = 'Published') AS published,
|
||
COUNT(*) FILTER (WHERE status = 'Published' AND content != '') AS published_with_content,
|
||
COUNT(*) FILTER (WHERE status = 'Published' AND "contentScannedAt" IS NULL) AS unscanned_published
|
||
FROM "Article";
|
||
```
|
||
|
||
Small corpus → Path A is fine. Large corpus → Path B pays for itself.
|
||
|
||
**Also re-confirm**: was `articles: ['public']` the state on `main` before this branch opened it? Check `git blame` on `src/server/services/feature-flags.service.ts`. If Green already saw articles on `main`, the framing shifts from "this branch opens legacy content to Green" to "this branch changes which legacy articles are visible to Green", which changes the urgency but not the fact that the leak window exists.
|
||
|
||
---
|
||
|
||
## 6. Monitoring (first 24h)
|
||
|
||
### Axiom streams to tail
|
||
|
||
| Stream | What to watch for |
|
||
|---|---|
|
||
| `article-text-moderation` | Error count for synchronous submissions from `upsertArticle` |
|
||
| `article-image-linking` | Failures linking extracted images (non-blocking, but indicates bad content) |
|
||
| `text-moderation-result` | `Stale workflow callback ignored` warnings (benign), `Workflow failed/expired` errors, unexpected statuses |
|
||
| `article-image-scan` | Scan completion rate, error count, processing status transitions |
|
||
|
||
### Postgres sanity queries
|
||
|
||
```sql
|
||
-- Text moderation backlog — should drain steadily
|
||
SELECT status, COUNT(*)
|
||
FROM "EntityModeration"
|
||
WHERE "entityType" = 'Article'
|
||
GROUP BY status;
|
||
|
||
-- Articles stuck in Processing — should not grow unbounded
|
||
SELECT COUNT(*) FROM "Article" WHERE status = 'Processing';
|
||
|
||
-- Articles with Pending image connections still in Processing — find stuck ones
|
||
SELECT a.id, a.title, COUNT(*) AS pending_images
|
||
FROM "Article" a
|
||
JOIN "ImageConnection" ic ON ic."entityId" = a.id AND ic."entityType" = 'Article'
|
||
JOIN "Image" i ON ic."imageId" = i.id
|
||
WHERE a.status = 'Processing' AND i.ingestion = 'Pending'
|
||
GROUP BY a.id, a.title
|
||
ORDER BY pending_images DESC
|
||
LIMIT 20;
|
||
|
||
-- Recent auto-unpublish events from text moderation
|
||
SELECT em."entityId", a.title, em."triggeredLabels", em.blocked, em."updatedAt"
|
||
FROM "EntityModeration" em
|
||
JOIN "Article" a ON a.id = em."entityId"
|
||
WHERE em."entityType" = 'Article'
|
||
AND em.blocked = true
|
||
AND em."updatedAt" > NOW() - INTERVAL '24 hours'
|
||
ORDER BY em."updatedAt" DESC;
|
||
|
||
-- Articles whose nsfwLevel changed recently (to spot migration churn)
|
||
SELECT COUNT(*)
|
||
FROM "Article"
|
||
WHERE "updatedAt" > NOW() - INTERVAL '1 hour';
|
||
```
|
||
|
||
### Key alerts to set (if not already)
|
||
|
||
- `EntityModeration.Pending` older than 1h, count > 100 → orchestrator is backlogged or callback is broken
|
||
- `Article.status = 'Processing'` older than 30min, count > 50 → image scan queue is backed up
|
||
- Spike in `status = 'UnpublishedViolation'` — possible xGuard false-positive burst; consider temporarily rolling back the handler dispatch
|
||
|
||
---
|
||
|
||
## 7. Rollback procedures
|
||
|
||
Ordered from fastest (lowest impact) to nuclear.
|
||
|
||
### 7.1 Disable image scanning pipeline (no code)
|
||
|
||
Toggle `articleImageScanning` in `feature-flags.service.ts`:
|
||
|
||
```ts
|
||
articleImageScanning: [], // was ['public']
|
||
```
|
||
|
||
**Effect**:
|
||
- 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
|
||
|
||
### 7.2 Disable text moderation enforcement (requires code change)
|
||
|
||
Re-comment the handler dispatch block in `src/pages/api/webhooks/text-moderation-result.ts:114-123`:
|
||
|
||
```ts
|
||
// const handler = entityHandlers[entityType];
|
||
// if (handler) {
|
||
// await handler({ ... });
|
||
// }
|
||
```
|
||
|
||
**Effect**:
|
||
- xGuard continues submitting, `EntityModeration` rows continue being written with full results
|
||
- No `article.nsfw` flips, no `updateArticleNsfwLevels` calls, no `UnpublishedViolation` transitions
|
||
- Previously-enforced articles stay as they are — not auto-restored
|
||
|
||
This is what to do if you see a false-positive burst and need to stop the bleeding immediately.
|
||
|
||
### 7.3 Restrict articles back to Civitai Blue/Red
|
||
|
||
```ts
|
||
articles: ['blue', 'red', 'public'], // revert to pre-branch
|
||
```
|
||
|
||
**Effect**: removes Civitai Green access to articles, reverts the rollout scope for this feature. Image scanning and text moderation continue running for existing audiences.
|
||
|
||
### 7.4 Full revert
|
||
|
||
Revert the branch merge. Note that:
|
||
- **Data changes from §2.3 (nsfwLevel mask shape) do NOT roll back** — they're already persisted in the DB. Post-revert, the old `bit_or` code will see single-bit masks and behave correctly on them, but there's no harm done either way.
|
||
- **`EntityModeration` rows stay.** The table is not dropped.
|
||
- **`ImageConnection` rows stay.** The new content image links remain in the DB; without the linking code running, new edits won't maintain them but old ones are fine.
|
||
- Restoring articles auto-unpublished by text moderation is a manual moderator action.
|
||
|
||
---
|
||
|
||
## 8. Contacts / runbook pointers
|
||
|
||
- **Companion architecture doc**: [`docs/article-content-scanning.md`](./article-content-scanning.md)
|
||
- **xGuard orchestrator workflows**: `src/server/services/orchestrator/orchestrator.service.ts`
|
||
- **Retry job**: `src/server/jobs/text-moderation-retry.ts` (`*/15 * * * *`)
|
||
- **Webhook endpoints**: `src/pages/api/webhooks/image-scan-result.ts`, `src/pages/api/webhooks/text-moderation-result.ts`
|
||
- **Backfill endpoints** (`WebhookEndpoint`-gated): `/api/admin/temp/migrate-article-images`, `/api/admin/temp/migrate-article-text-moderation`
|
||
- **Flipt flag**: `articleImageScanning` is feature-flag gated in code (not Flipt). To change, edit `feature-flags.service.ts` and redeploy.
|
||
|
||
---
|
||
|
||
**Doc owner**: whoever merges civitai/civitai#1879
|
||
**Last updated**: 2026-04-10
|