← 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 })
  }
}