Demonstration of prompt engineering with LangChain using structured outputs and conditional edges to generate high-quality technical articles through multiple AI agents reviewing each other.
This project exemplifies:
- Structured Outputs: Using Zod schemas to prevent hallucinations
- Prompt Chaining: Three-stage pipeline with quality feedback loop
- Minimal Code: Let AI agents review each other instead of complex logic
- Real API Testing: Integration tests with actual OpenRouter calls
- Quality Assurance: Automatic retry until score ≥ 8/10
- 🎨 3-Stage Pipeline: Plan → Draft → Review (with quality loop)
- 📊 Structured Validation: Zod schemas at every step
- 🔄 Conditional Edges: Retry review until quality threshold met
- 📝 Template System: JSON prompts with variable interpolation
- 🧪 Real API Tests: No mocks, actual LLM calls
- 📁 Organized Outputs:
outputs/timestamp-topic/output.md
START → plan → draft → review ⟲ (if score < 8) → END
↓ ↓ ↓
outline article final + scores
src/
├── config.ts # Configuration with env vars
├── index.ts # CLI entry point
├── graph/
│ ├── graph.ts # StateGraph with conditional edges
│ ├── factory.ts # Graph builder
│ └── nodes/
│ ├── planNode.ts # Outline generation (Zod validated)
│ ├── draftNode.ts # Article drafting
│ └── reviewNode.ts # Quality scoring & improvement
├── services/
│ └── openrouter-service.ts # LLM client
└── utils/
└── prompt-loader.ts # Template loading & interpolation
prompts/
└── v1/
├── plan.json # Outline generation prompt
├── draft.json # Section writing prompt
└── review.json # Quality review prompt
tests/
└── article-generator.test.ts # Real API integration test
│ ├── graph.ts # StateGraph definition with co-located types │ ├── factory.ts # Graph creation factory │ └── nodes/ # LangGraph nodes (workflow steps) │ ├── outline.node.ts # Generate article structure + parsing │ ├── research.node.ts # Research sections in parallel │ ├── write.node.ts # Write sections + assembly │ └── review.node.ts # Polish final article ├── services/ │ └── openrouter-service.ts # OpenRouter SDK wrapper (implements LLMClient) └── utils/ └── prompt-loader.ts # Load prompts from template files
prompts/ # Prompt templates with variables ├── outline.txt # Section structure generation ├── research.txt # Research individual sections ├── write-section.txt # Write section content └── review.txt # Review and improve
tests/ └── article-generator.test.ts # Graph workflow tests
## Installation
```bash
npm install
Create .env file:
# OpenRouter Configuration (required)
OPENROUTER_API_KEY=sk-or-v1-...
OPENROUTER_MODEL=anthropic/claude-3.5-sonnet
OPENROUTER_HTTP_REFERER=https://your-site.com
OPENROUTER_X_TITLE=Article Generator
# Model Configuration
MODEL_TIMEOUT=60000
MODEL_MAX_RETRIES=3
# Article Configuration
MIN_SECTIONS=3
MAX_SECTIONS=8
TARGET_WORDS_PER_SECTION=200
# Logging
LOG_LEVEL=info# Using topic flag
npm run generate -- --topic "Test-Driven Development in TypeScript"
# With custom output path
npm run generate -- --topic "Docker Best Practices" --output my-article.mdnpm testGenerates article structure:
- Title
- Introduction
- Sections with key points
- Conclusion
State Updates: outline, currentStep
Researches all sections in parallel:
const researchPromises = sections.map(section =>
llmClient.generate(researchPrompt)
);
const results = await Promise.all(researchPromises);State Updates: researchResults, currentStep
Writes each section sequentially using research:
- Loops through sections
- Uses section research + key points
- Calculates word count
- Builds draft article
State Updates: sections, draftArticle, totalWords, currentStep
Reviews and improves final article:
- Checks tone and style
- Improves transitions
- Ensures consistency
- Polishes language
State Updates: finalArticle, currentStep
Defines the workflow with typed state:
const ArticleStateAnnotation = Annotation.Root({
topic: Annotation<string>,
outline: Annotation<any>,
researchResults: Annotation<string[]>,
sections: Annotation<any[]>,
draftArticle: Annotation<string>,
finalArticle: Annotation<string>,
totalWords: Annotation<number>,
currentStep: Annotation<string>,
});Each node receives state and returns partial state updates:
export const createOutlineNode = (llmClient: LLMClient) => {
return async (state: GraphState): Promise<Partial<GraphState>> => {
const outline = await generateOutline(state.topic);
return {
outline,
currentStep: 'outline_completed',
};
};
};const workflow = new StateGraph({ stateSchema: ArticleStateAnnotation })
.addNode('generateOutline', outlineNode)
.addNode('conductResearch', researchNode)
.addNode('writeSections', writeSectionsNode)
.addNode('reviewArticle', reviewNode)
.addEdge(START, 'generateOutline')
.addEdge('generateOutline', 'conductResearch')
.addEdge('conductResearch', 'writeSections')
.addEdge('writeSections', 'reviewArticle')
.addEdge('reviewArticle', END);
return workflow.compile();Uses MockLLMClient with deterministic responses:
class MockLLMClient implements LLMClient {
responses: Map<string, string>;
async generate(prompt: string): Promise<string> {
if (prompt.includes('outline')) return mockOutline;
if (prompt.includes('Research')) return mockResearch;
if (prompt.includes('Write')) return mockSection;
if (prompt.includes('Review')) return mockReview;
}
}Tests verify:
- ✅ Complete article generation through graph
- ✅ Multiple LLM calls in chain
- ✅ Correct state flow through all nodes
- ✅ Word count calculation
- Nodes: One transformation per node
- Services: LLM interactions only
- Utils: Reusable helpers (prompt loading)
- Config: Environment management
Nodes receive dependencies as parameters:
createOutlineNode(llmClient: LLMClient, config: ArticleConfig)Nodes return new state objects, never mutate:
return {
...state,
outline: newOutline,
};Prompts stored in files, not code:
const prompt = await PromptLoader.load('outline', {
topic: state.topic,
minSections: config.minSections,
maxSections: config.maxSections,
});- Prompt Chaining: Build complex outputs from simple steps
- LangGraph: State management in LLM workflows
- Parallel Execution: Research sections concurrently
- Sequential Processing: Write sections in order
- State Transitions: Track progress through workflow
- Testing: Mock LLMs for deterministic tests
Requires Node.js >= 22.0.0 for TypeScript strip-types support.
MIT