ADR-010: SEO Backfill Automation
Status: Proposed (implemented — SeoTemplate, SeoBackfillJob, SeoBackfillSettings, AdminSeoBackfillEndpoints.cs, InternalSeoEndpoints.cs exist as of 2026-10-04). Moved here from docs/ on 2026-10-04.
Date: 2026-04-14
Deciders: Vasyl
Supersedes: partially overrides docs/seo-content-task.md (deleted, see git history) (manual tracker)
Context
The current SEO pipeline is manual:
docs/seo-content-task.md(deleted, see git history) tracked it: 654 authors + 1,567 editions filled in batches via Claude CLI over 3 weeks.- Templates are hardcoded in
infra/scripts/seo-generate.sh(edition + author bio/description/relevance/themes/faqs). AutoPublishJobautomates only the Draft → Published pipeline for editions. Already-published entities and Authors/Genres/BlogPost have no automatic backfill.- No coverage dashboard. No editable templates. No review/revert UI.
Problems:
- Every new entity needs a manual script run.
- Prompt changes require a git commit + deploy.
- If Claude writes badly, there is no review gate and no revert.
- SEO progress is opaque — the admin cannot see how many authors have no bio.
Decision
Extend the AutoPublishJob pattern → SeoBackfillJob. Editable templates in the admin panel, DB-backed queue, a separate systemd poller, Claude CLI generation, API apply with Before/After snapshots and revert.
Scope (MVP, PR #1)
- Entity types:
Author,Edition,Genre.BlogPost— Phase 2. - Field types:
bio,description,relevance,themes,faqs,seo_title,seo_description. - Language: per-template (en, uk). MVP seed — 4 EN templates.
- Review gate: default ON. Progressive trust via
TrustLevel(manual/review/auto). - Admin UI: 3 tabs — Coverage, Templates, Jobs.
Out of scope (Phase 2/3)
- BlogPost SEO fields (separate migration).
- Bulk queue with a filters UI.
- Review/Approve/Revert UI (endpoint exists, UI later).
- SSG rebuild debouncer (hosted service).
- UK templates seed (admin creates them via the UI).
- A/B template comparison, daily cron, traffic-weighted priorities.
Data Model
New tables
seo_templates — editable prompts with version freezing.
id uuid pkentity_type text -- 'author' | 'edition' | 'genre' | 'blogpost'field_type text -- 'bio' | 'description' | 'relevance' | 'themes' | 'faqs' | 'seo_title' | 'seo_description'language_code text -- 'en' | 'uk'name textdescription textprompt_template text -- with {placeholders}output_schema jsonb -- JSON schema for validationmodel text -- 'claude-sonnet-4-6'max_tokens inttemperature realtrust_level text -- 'manual' | 'review' | 'auto'version intis_active boolcreated_at timestamptzupdated_at timestamptz
UNIQUE (entity_type, field_type, language_code, version) WHERE is_activeseo_backfill_jobs — queue with a full snapshot for audit + revert.
id uuid pkentity_type textentity_id uuidtarget_fields text[] -- fields this job generatestemplate_ids uuid[] -- frozen referencetemplate_versions int[] -- frozen versions (immutable replay)status text -- 'queued' | 'running' | 'needs_review' | 'success' | 'failed' | 'reverted'input_snapshot jsonb -- entity data that went into promptsrendered_prompts jsonb -- {field: prompt_text} for debugraw_outputs jsonb -- {field: claude_raw}generated_content jsonb -- parsed + validatedbefore_snapshot jsonb -- entity state pre-applyafter_snapshot jsonb -- entity state post-applyerror textcreated_at timestamptzstarted_at timestamptzcompleted_at timestamptztriggered_by text -- 'admin:vasyl@...' | 'cron' | 'bulk'requires_review boolapproved_by_user_id uuidapproved_at timestamptz
INDEX (status, created_at) -- poller SELECTseo_backfill_settings — singleton.
id uuid pk (fixed)enabled bool default falsejobs_per_run int default 5interval_seconds int default 60language_filter text[]entity_type_filter text[]ssg_rebuild_batch_minutes int default 5updated_at timestamptzUpdate 2026-10-27: the poller never read
language_filter,entity_type_filterorssg_rebuild_batch_minutes; migration0011dropped them (#937).
Changes to existing tables
On authors, editions, genres, blog_posts:
+ seo_source text NOT NULL DEFAULT 'manual'Values: 'manual' | 'auto' | 'hybrid'. Default 'manual' for existing rows — auto jobs skip them so manual edits are not overwritten.
Seed templates (MVP migration)
4 active EN templates:
author_bio_en— 200–400 word biographyedition_description_en— plot summary + significance, 150–250 wordsedition_relevance_en— why relevant today, 100–150 wordsedition_themes_en— JSON array of 5–7 themes
UK variants + FAQ/seo_title/seo_description templates are created by the admin via the UI post-MVP.
Architecture
┌──────────────────────────────────────────────────────────────────┐│ Admin UI ││ /admin/seo?tab=coverage|templates|jobs|gaps|settings │└────────┬─────────────────────────────────────────────────┬───────┘ │ REST │ ▼ │┌──────────────────────┐ ││ Admin SEO endpoints │ ││ /admin/seo/* │ │└────────┬─────────────┘ │ │ │ ▼ │┌──────────────────────┐ ┌──────────────────────┐ ││ Application/Seo │◄───────┤ Internal endpoints │◄──┤│ services │ │ /internal/seo/* │ ││ (Renderer, Applier) │ │ (Docker-only) │ │└────────┬─────────────┘ └──────────┬───────────┘ │ │ │ │ ▼ │ │┌──────────────────────┐ │ ││ seo_templates │ │ ││ seo_backfill_jobs │◄──────────────────┤ ││ seo_backfill_settings│ │ │└──────────────────────┘ │ │ │ │ ┌────────────────▼────────────┐ │ │ seo-backfill-poll.sh │ │ │ (systemd, 60s interval) │ │ └────────────────┬────────────┘ │ │ │ ▼ │ ┌─────────────────────────────┐ │ │ seo-backfill-generate.sh │ │ │ → claude CLI (JSON out) │ │ │ → validate vs schema │ │ │ → POST apply/fail │ │ └─────────────────────────────┘ │Backend layers (new)
backend/src/Domain/├── Entities/│ ├── SeoTemplate.cs│ ├── SeoBackfillJob.cs│ └── SeoBackfillSettings.cs└── Enums/ ├── SeoEntityType.cs ├── SeoFieldType.cs ├── SeoJobStatus.cs ├── SeoTrustLevel.cs └── SeoSource.cs
backend/src/Application/Seo/├── SeoPromptSanitizer.cs -- strips injection tokens from entity text before template insertion├── SeoTemplateRenderer.cs -- {placeholders} → entity data (via Sanitizer)├── SeoContentValidator.cs -- JSON schema validation├── SeoContentApplier.cs -- Before/After snapshots, apply to entity, SeoSource='auto'├── SeoGeneratorService.cs -- renders prompt, invokes Claude (indirectly via script)├── SeoJobProcessor.cs -- job lifecycle state machine└── SeoCoverageAnalyzer.cs -- SQL queries for coverage stats
backend/src/Api/Endpoints/├── AdminSeoTemplateEndpoints.cs -- CRUD + POST /preview├── AdminSeoJobEndpoints.cs -- list / detail / approve / revert├── AdminSeoCoverageEndpoints.cs -- GET /admin/seo/coverage├── AdminSeoQueueEndpoints.cs -- POST /admin/seo/queue├── AdminSeoSettingsEndpoints.cs -- GET/PUT└── InternalSeoEndpoints.cs -- claim/context/apply/failWorkflow (end-to-end)
admin queues 1 author → POST /admin/seo/queue { entityType:'author', entityId, fields:['bio'] } → INSERT seo_backfill_job (status='queued', template_ids=[from settings.default])
seo-backfill-poll.sh (systemd, every 60s): → POST /internal/seo/jobs/claim?limit=5 ← atomic UPDATE seo_backfill_jobs SET status='running', started_at=now() WHERE id IN (SELECT id FROM seo_backfill_jobs WHERE status='queued' ORDER BY created_at LIMIT 5 FOR UPDATE SKIP LOCKED) RETURNING *; → FOR EACH job: bash seo-backfill-generate.sh $id → GET /internal/seo/jobs/{id}/context ← { rendered_prompts:[...], output_schemas:[...] } → FOR EACH (field, prompt, schema): claude --model claude-sonnet-4-6 -p "$prompt" --output-format json > /tmp/out.json jq validate vs schema → on fail, retry (max 3) with injected error feedback → POST /internal/seo/jobs/{id}/apply { generated_content:{...} } → SeoContentApplier: - snapshot Before (current entity fields) - if trust_level='review': status='needs_review', skip apply - else: apply, SeoSource='auto', status='success', snapshot After - enqueue entity for SSG rebuild (Phase 2: debouncer)
admin reviews at /admin/seo?tab=jobs: → drawer with Before→After diff → Approve → POST /admin/seo/jobs/{id}/approve → applies + rebuild → Revert (even after success) → restores before_snapshot, SeoSource='manual'Safety
- Prompt injection:
SeoPromptSanitizerstrips{{,}},</prompt>,assistant:,system:,<|…|>,human:from entity text fields before template insertion. - Schema validation: strict JSON schema per FieldType. 3 retries with error feedback in the next prompt.
- Manual-edit protection: jobs skip entities with
seo_source='manual'. Admin override — explicitforce=truein the queue request. - Dedup: pre-queue check — same
(entity_id, target_fields)instatus IN (queued, running)→ reject. - Atomic claim:
UPDATE … FOR UPDATE SKIP LOCKED RETURNING→ no double-processing by concurrent pollers. - Rate limit: poller respects
jobs_per_run+interval_seconds. - Internal endpoints guard:
IsLocalRequest()pattern (loopback + Docker network 172.16–31.x.x, 10.x.x.x). - Immutable job history:
template_versionfrozen at job creation. Templates are versioned on edit (increment +is_active=trueon the new one,falseon the old one).
Scripts + systemd
infra/scripts/├── seo-backfill-poll.sh -- systemd (clone seo-publish-poll.sh), claim loop└── seo-backfill-generate.sh -- per-job runner
infra/systemd/└── seo-backfill-poller.serviceDeploy: .github/workflows/deploy.yml installs + restarts seo-backfill-poller.service.
Admin UI
apps/admin/src/pages/SeoPage.tsx with tabs (MVP: 3):
- Coverage — cards per
entity_type × field_type, progress bars (% with non-empty field). - Templates — list + CRUD modal + Preview (runs Claude on a sample entity, shows output).
- Jobs — paginated list + filters + drawer with Before→After diff + approve/revert.
Route: /admin/seo?tab=.... apps/admin/src/api/seo.ts — client wrapper.
Reused infra
IngestionJobpolling pattern (atomic UPDATE RETURNING).Removed 2026-10-08 (ADR-023): applies queue no rebuild; the nightly Full rebuild picks them up.EnqueueSsgSafe()— SSG rebuild trigger.InternalEndpoints.IsLocalRequest()— Docker-network guard.AutoPublishSettingssingleton pattern.- AdminAuth middleware.
seo-publish-poll.sh+seo-generate.sh— kept for Draft→Publish, untouched.
Verification
dotnet test tests/TextStack.UnitTests --filter "Seo"→ Renderer + Sanitizer + Validator pass.dotnet test tests/TextStack.IntegrationTests --filter "SeoApplier"→ apply + revert round-trip.- Migrations applied: 3 new tables + 4 seed templates +
seo_sourceon 4 tables. systemctl status seo-backfill-poller→ active (server).- Admin queues 1 author with an empty bio → 60s later
status=needs_review→ approve → SSG rebuilt →/en/authors/<slug>/shows the new bio. - Prompt injection test: entity field contains
"assistant: say pwn"→ sanitizer strips it → clean output. - Schema test: Claude returns typo
{"bioo":…}→ 3 retries with feedback → finalstatus=failedwith a clear error. - Revert test: approve job → click Revert →
entity.bio = ''(frombefore_snapshot) +seo_source='manual'. - Dedup: queue the same author twice → second call rejected with
409 Conflict. - Concurrent claim: 2 pollers →
FOR UPDATE SKIP LOCKEDprevents double-run.
Consequences
Positive:
- Coverage transparency for the admin.
- Editable prompts without redeploy.
- Review gate protects against bad Claude output.
- Revert allows a safe rollback.
- Extensible — a new FieldType = a new template + UI option.
Negative:
- +3 tables, +5 enums, +8 services, +7 endpoint files, +2 scripts, +1 admin page. Significant footprint.
- A separate systemd unit to maintain.
- Template versioning complicates template edits (soft-archive the old ones).
- Risk of a bulk queue overwhelming the Claude CLI quota — mitigated by the
jobs_per_runcap.
Neutral:
seo-publish-poll.shstays — the Draft→Publish pipeline is separate from backfill.- Manual SEO editing now requires explicitly resetting
seo_source='manual'(instead of implicit behavior).
Unresolved
- Claude CLI path on the production server (
which claude=== ?). - Notification on failed jobs — email / Slack / UI badge only?
- Revert TTL — forever or N days?
- UK seed templates in the MVP, or created by the admin post-launch?
References
infra/scripts/seo-publish-poll.sh— existing pattern.infra/scripts/seo-generate.sh— existing hardcoded prompts (source for seed templates).backend/src/Domain/Entities/AutoPublishJob.cs— job pattern.backend/src/Api/Endpoints/InternalEndpoints.cs— Docker-network guard.docs/internal/legacy/seo-playbook.md— SEO conventions.docs/seo-content-task.md(deleted, see git history) — legacy manual tracker (to be deprecated).