← back to Letsbegin
app/api/ralph/convert/route.ts
244 lines
import { NextRequest, NextResponse } from 'next/server'
import { logGemini } from '@/lib/cost-track'
import * as fs from 'fs'
import * as path from 'path'
// Gemini API configuration (FREE - no credits needed!)
const GEMINI_API_KEY = process.env.GEMINI_API_KEY
if (!GEMINI_API_KEY) {
throw new Error('GEMINI_API_KEY environment variable is required')
}
const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent'
async function callGemini(prompt: string): Promise<string> {
const response = await fetch(`${GEMINI_ENDPOINT}?key=${GEMINI_API_KEY}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { temperature: 0.3, maxOutputTokens: 4000 }
})
})
if (!response.ok) {
const error = await response.text()
throw new Error(`Gemini API error: ${error}`)
}
const data = await response.json()
logGemini(data, { note: 'ralph-convert' })
return data.candidates?.[0]?.content?.parts?.[0]?.text || ''
}
interface UserStory {
id: string
title: string
description: string
acceptanceCriteria: string[]
priority: number
passes: boolean
notes: string
}
interface RalphData {
project: string
branchName: string
description: string
userStories: UserStory[]
}
interface ValidationResult {
valid: boolean
issues: string[]
}
// Validate the converted prd.json follows Ralph skill rules
function validateRalphData(data: RalphData): ValidationResult {
const issues: string[] = []
// Check each story
data.userStories.forEach((story, idx) => {
// Check story sizing (description should be 2-3 sentences)
const sentences = story.description.split(/[.!?]+/).filter(s => s.trim()).length
if (sentences > 4) {
issues.push(`${story.id}: Description too long (${sentences} sentences). Should be 2-3 sentences max.`)
}
// Check for "Typecheck passes" criterion
const hasTypecheck = story.acceptanceCriteria.some(c =>
c.toLowerCase().includes('typecheck') || c.toLowerCase().includes('type check')
)
if (!hasTypecheck) {
issues.push(`${story.id}: Missing "Typecheck passes" acceptance criterion`)
}
// Check UI stories have browser verification
const isUIStory = story.title.toLowerCase().includes('ui') ||
story.title.toLowerCase().includes('component') ||
story.title.toLowerCase().includes('display') ||
story.title.toLowerCase().includes('page') ||
story.description.toLowerCase().includes('user interface')
const hasBrowserCheck = story.acceptanceCriteria.some(c =>
c.toLowerCase().includes('browser') || c.toLowerCase().includes('verify in browser')
)
if (isUIStory && !hasBrowserCheck) {
issues.push(`${story.id}: UI story missing "Verify in browser" criterion`)
}
// Check acceptance criteria are specific (not vague)
story.acceptanceCriteria.forEach(criterion => {
const vagueTerms = ['works correctly', 'functions properly', 'is good', 'is done', 'is complete']
if (vagueTerms.some(term => criterion.toLowerCase().includes(term))) {
issues.push(`${story.id}: Vague criterion "${criterion}" - must be specific and verifiable`)
}
})
})
// Check ordering (schema/DB before backend before UI)
const storyTypes = data.userStories.map(s => {
if (s.title.toLowerCase().includes('schema') || s.title.toLowerCase().includes('migration') || s.title.toLowerCase().includes('database')) return 'db'
if (s.title.toLowerCase().includes('api') || s.title.toLowerCase().includes('backend') || s.title.toLowerCase().includes('server')) return 'backend'
if (s.title.toLowerCase().includes('ui') || s.title.toLowerCase().includes('component') || s.title.toLowerCase().includes('page')) return 'ui'
return 'other'
})
let lastType = 'db'
const typeOrder = { db: 0, backend: 1, other: 2, ui: 3 }
storyTypes.forEach((type, idx) => {
if (typeOrder[type] < typeOrder[lastType as keyof typeof typeOrder]) {
issues.push(`Story ordering issue: ${data.userStories[idx].id} (${type}) should come before earlier ${lastType} stories`)
}
lastType = type
})
return {
valid: issues.length === 0,
issues
}
}
// Archive existing prd.json if it exists
function archiveExistingPrd(projectPath: string): string | null {
const ralphDir = path.join(projectPath, 'scripts', 'ralph')
const prdPath = path.join(ralphDir, 'prd.json')
const progressPath = path.join(ralphDir, 'progress.txt')
if (fs.existsSync(prdPath)) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
const archiveDir = path.join(ralphDir, 'archive', timestamp)
fs.mkdirSync(archiveDir, { recursive: true })
fs.copyFileSync(prdPath, path.join(archiveDir, 'prd.json'))
if (fs.existsSync(progressPath)) {
fs.copyFileSync(progressPath, path.join(archiveDir, 'progress.txt'))
}
return archiveDir
}
return null
}
export async function POST(request: NextRequest) {
try {
let body: { prdMarkdown?: string; projectName?: string; projectPath?: string }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 })
}
const { prdMarkdown, projectName, projectPath } = body
if (!prdMarkdown) {
return NextResponse.json({ error: 'PRD markdown required' }, { status: 400 })
}
// Archive existing prd.json if present
let archivedTo: string | null = null
if (projectPath) {
archivedTo = archiveExistingPrd(projectPath)
}
const prompt = `You are the Ralph PRD Converter. Convert this PRD to prd.json format for autonomous agent execution.
PRD Content:
${prdMarkdown}
Project Name: ${projectName || 'Unknown'}
## Output Format
Generate a JSON object with this exact structure:
{
"project": "[Project Name]",
"branchName": "ralph/[feature-name-kebab-case]",
"description": "[Feature description from PRD]",
"userStories": [
{
"id": "US-001",
"title": "[Concise title]",
"description": "As a [user], I want [feature] so that [benefit]",
"acceptanceCriteria": [
"Specific verifiable criterion 1",
"Specific verifiable criterion 2",
"Typecheck passes"
],
"priority": 1,
"passes": false,
"notes": ""
}
]
}
## CRITICAL SIZING RULE
**Each story must be completable in ONE Ralph iteration (one context window).**
- Story description: 2-3 sentences MAXIMUM
- Right-sized: "Add status column to users table", "Create UserCard component", "Add API endpoint for stats"
- TOO BIG: "Build entire dashboard", "Implement full auth system" - SPLIT THESE!
## ORDERING RULE
Stories execute sequentially by priority. Order by dependency:
1. Database/schema changes (migrations)
2. Backend logic (API endpoints, server actions)
3. UI components (that use the backend)
No story can depend on a later story!
## ACCEPTANCE CRITERIA RULES
- MUST be verifiable (specific, checkable)
- EVERY story MUST include: "Typecheck passes"
- UI stories MUST include: "Verify in browser using dev-browser skill"
- BAD: "works correctly", "functions properly" (too vague)
- GOOD: "Returns 200 status with user object", "Button is disabled when loading"
Return ONLY the JSON object. No markdown formatting, no explanation.`
const text = await callGemini(prompt)
// Parse JSON from response
let ralphData: RalphData
try {
// Try to extract JSON if wrapped in markdown
const jsonMatch = text.match(/\{[\s\S]*\}/)
if (jsonMatch) {
ralphData = JSON.parse(jsonMatch[0])
} else {
ralphData = JSON.parse(text)
}
} catch {
console.error('Failed to parse Ralph JSON:', text)
return NextResponse.json({ error: 'Failed to parse Ralph data' }, { status: 500 })
}
// Validate the converted data against Ralph skill rules
const validation = validateRalphData(ralphData)
return NextResponse.json({
ralphData,
success: true,
validation,
archivedTo
})
} catch (error) {
console.error('Failed to convert to Ralph format:', error)
return NextResponse.json({ error: 'Failed to convert' }, { status: 500 })
}
}