A production-ready, multi-agent LangGraph pipeline for generating SEO-optimized articles
with real-time monitoring, crash recovery, and comprehensive quality assurance — deployed
on AWS Lambda with Bedrock-powered Claude models.
The SEO Article Generation System is a sophisticated application that automatically generates
high-quality, SEO-optimized articles through a 7-node multi-agent AI pipeline.
It combines real-time research via SerpAPI, intelligent content generation via Claude or GPT-4,
comprehensive quality assurance, and crash-recovery capabilities.
What the pipeline does
Input
→
Research
→
Outline
→
Write
→
QA Score
→
Article
Accepts user input: topic, desired word count, language
Conducts research: fetches real SERP data, competitor structures, FAQs
Generates outline: SEO-aligned article structure with keyword mapping
Writes content: section-by-section generation with internal/external linking
Validates quality: 15+ SEO checks with penalty/bonus scoring
Revision loop: rewrites until SEO score ≥ 80 or max 3 revisions reached
Returns result: final article + metadata, keywords, links, SEO score
Production-grade: Every component is async, all data flows through a strongly-typed
Pydantic state model, PostgreSQL checkpointing enables crash recovery, and log streaming
keeps you observable at all times.
02
System Architecture & Pipeline
The system uses a LangGraph StateGraph with conditional routing, per-node retry logic, and persistent PostgreSQL checkpointing.
INPUT { topic, word_count, language }
↓
🚪FastAPI GatewayPOST /jobs/
↓
🎭ORCHESTRATORReAct
↓
🔍RESEARCHReAct · 3 retries
↓
📐OUTLINEReAct · 3 retries
↓
✍️WRITERSequential · 3 retries
↓
✅QA (score ≥ 80?)ReAct
PASS→ Output
FAIL < 80↺ Writer (max 3)
MAX REVISIONS→ Best-effort
↓
📦OUTPUT BUILDERPlain fn
↓
OUTPUT { article, seo_metadata, score, links }
Every node that fails past its retry budget routes to an ERROR HANDLER terminal node. The entire state object (ArticleGenerationState) is the single source of truth — no side channels, no globals.
03
Agent Node Catalogue
Six operational nodes plus one terminal error handler. Each has an independent retry budget.
Why is the Writer Sequential, not ReAct?
The article writer calls tools in a deterministic order:
article_writer → linking_tool → metadata_generator.
A ReAct agent loop adds no value here and risks non-determinism in section ordering.
Direct sequential calls give precise control over section-by-section generation.
04
Conditional Routing Logic
LangGraph conditional edges route the graph after each node using simple boolean checks on the shared state object.
After Node
Condition
Destination
RESEARCH
retry_counts["research"] ≥ 3
→ error_handler
serp_results present
→ outline
else
↺ research (retry)
OUTLINE
retry_counts["outline"] ≥ 3
→ error_handler
outline present
→ writer
WRITER
retry_counts["writer"] ≥ 3
→ error_handler
article_draft present
→ qa
QA
status == "done"
→ output_builder
revision_count ≥ 3
→ output_builder (best-effort)
score < 80
↺ writer (revision loop)
The QA → Writer loop passes accumulated qa_result.issues and qa_result.suggestions
directly in state — the Writer receives first-class feedback on each revision cycle.
05
Database Architecture
PostgreSQL is used for two distinct purposes with completely separate code paths — both in the same database instance.
SQLAlchemy Async ORM
generation_jobs
job_id UUID PK
topic TEXT
word_count INT
status VARCHAR(20)
thread_id TEXT UNIQUE
seo_score INT
error_message TEXT
timestamps
generated_articles
id UUID PK
job_id UUID FK
final_article TEXT
seo_metadata JSON
keywords JSON
internal_links JSON
external_refs JSON
seo_score INT
LangGraph PostgresSaver
checkpoints
thread_id TEXT
checkpoint_ns TEXT
checkpoint_id TEXT
parent_checkpoint_id
checkpoint_writes
task writes per node
checkpoint_blobs
serialized state blobs
Key: seo-job:{job_id}
Links back to generation_jobs.thread_id
Crash Resume Pattern
# Pipeline: Research ✅ → Outline ✅ → Writer 💥 CRASH# On restart — pass None as input to signal "resume":
graph.invoke(None, config={"configurable": {"thread_id": "seo-job:abc123"}})
# LangGraph finds last checkpoint → skips Research + Outline → retries Writer only.
06
AWS Infrastructure
The system is deployable as a serverless application using AWS SAM, Docker containers, and Lambda with API Gateway.
🌐
API Gateway (REST)
Routes all HTTP traffic. Endpoints: /jobs/*, /health, /docs
Table: SEO-article-write. Stores job metadata and generated articles. On-demand pricing.
🧠
AWS Bedrock
Model: anthropic.claude-sonnet-4-6. Used for all LLM calls via ChatBedrockConverse. Falls back to OpenAI if configured.
📋
CloudWatch Logs
Log group: /aws/lambda/SEOArticleGenerationAPI. 7-day retention. Real-time tailing via SAM CLI.
🔐
IAM Role
Least-privilege. Grants Lambda access to DynamoDB, Bedrock, and CloudWatch only.
LLM Provider Factory
The system uses a factory pattern to support both OpenAI and AWS Bedrock:
# .env
LLM_PROVIDER=bedrock
# Bedrock config
AWS_PROFILE=personal-dev-dev
BEDROCK_MODEL=anthropic.claude-sonnet-4-6
BEDROCK_WRITER_MODEL=anthropic.claude-sonnet-4-6
# Or switch to OpenAI with a single line
LLM_PROVIDER=openai
OPENAI_API_KEY=sk-proj-...
Why Bedrock? AWS Bedrock provides access to Claude Sonnet models without
direct Anthropic API calls — traffic stays within the AWS network, billing is unified,
and IAM controls access rather than API key management.
Serverless Deployment
Deployed via AWS SAM + Docker. UV package manager cuts build time from ~3 min (pip) to ~15 seconds.
File
Purpose
template.yaml
SAM/CloudFormation — Lambda, API Gateway, IAM roles
lambda_handler.py
Entry point wrapping FastAPI with Mangum
Dockerfile.lambda
Production image using UV · Python 3.12 base
samconfig.toml
SAM CLI config for dev/prod environments
07
Real-Time Log Streaming (SSE)
Every log record generated by the pipeline is broadcast to connected browsers via Server-Sent Events, giving you a live window into agent execution.
_BroadcastHandler (logging.Handler)
│ attaches to root logger — captures every record
▼ _buffer: deque(maxlen=400) ← ring buffer, newest evicts oldest
│
├──► _queues: List[asyncio.Queue] ← one Queue per SSE client
│
└──► loop.call_soon_threadsafe() ← safe cross-thread delivery
GET /logs/stream
1. Replay entire _buffer → new client catches up instantly
2. Listen on personal Queue (20s timeout)
3. Emit: data: {"level":"INFO","name":"...","text":"..."}
4. Send ": keep-alive" ping every 20s
5. Remove queue on client disconnect
Why asyncio.to_thread? The LangGraph graph is synchronous.
Running it directly would block the asyncio event loop, preventing SSE clients from receiving
live log updates during article generation. asyncio.to_thread offloads the graph
to a thread pool, keeping the event loop free for streaming.
08
QA Scoring Engine
The QA agent starts every article at a score of 100 and applies penalties for SEO issues. Pass threshold is 80.
keyword_in_h1
−15 pts · Primary keyword absent from H1
keyword_in_intro
−15 pts · Absent from first 500 chars
word_count_severe
−30 pts · <60% or >200% of target
word_count_significant
−20 pts · 60–75% of target
keyword_density_range
−10 pts · Density <0.5% or >3.0%
h2_keyword_coverage
−10 pts · Fewer than 2 H2s have keyword
title_tag_length
−10 pts · title_tag > 60 chars
meta_description_length
−10 pts · meta_desc > 160 chars
heading_hierarchy
−10 pts · H3 without parent H2
short_section
−5 pts · H2 section < 100 words
link_placeholders
−5 pts · Unresolved [LINK] markers
content_truncated
−5 pts · Article ends mid-sentence
If score < 80 and revision_count < 3, the QA agent routes back to the Writer with a structured qa_result containing issues and improvement suggestions. After 3 revisions, the best-effort article is published regardless.
09
Configuration System
All tunable parameters live in three YAML files under config/ — no hardcoded values in agent code.