Implement Phase 2: Core API Integration

- Add Claude API client with Anthropic SDK
  - Retry logic with exponential backoff (3 attempts)
  - Handle auth errors, rate limits, and server errors
  - Architecture classification with JSON parsing
  - Research query generation from architecture
- Add Perplexity API client with native fetch
  - Retry logic with exponential backoff
  - Parse responses with sources and citations
  - Execute multiple research queries
- Add prompt templates for architecture classification
- Add default query generation as fallback
- Add ArchitectureGenerator combining Claude + Perplexity
- Add 44 new tests (80 total, all passing)

All Phase 2 acceptance criteria met:
- Claude client sends prompts and retries on failure
- Perplexity client searches and parses with sources
- Architecture classification returns valid JSON
- Research queries generated (4-6 per architecture)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-01-10 12:17:42 +00:00
parent 606f27d6bb
commit ae0165a802
11 changed files with 1643 additions and 28 deletions

View File

@@ -1,37 +1,40 @@
--- ---
active: true active: true
iteration: 1 iteration: 1
max_iterations: 30 max_iterations: 40
completion_promise: "PHASE_1_COMPLETE" completion_promise: "PHASE_2_COMPLETE"
started_at: "2026-01-10T12:00:45Z" started_at: "2026-01-10T12:10:57Z"
--- ---
Execute Phase 1 - Foundation from PROMPT.md. Execute Phase 2 - Core API Integration from PROMPT.md.
You are building the Ralph PRD Generator CLI tool. Phase 1 is complete. Now implement Phase 2.
Read PROMPT.md for full specification. Read PROMPT.md for API specifications.
Read prd.json for feature tracking. Read prd.json for Phase 2 features.
Read CLAUDE.md for project configuration.
Phase 1 tasks: Phase 2 tasks:
1. Set up TypeScript project with all dependencies 1. Claude API client with Anthropic SDK
2. Configure tsup, vitest, eslint 2. Perplexity API client with fetch
3. Implement CLI framework with Commander.js 3. Architecture classification prompt and parser
4. Implement config management for API key storage 4. Research query generation
5. Create basic project structure 5. Tests for all API clients
6. Write tests for config and CLI
API Integration notes:
- Use environment variables for API keys during testing
- Implement retry logic with 3 attempts and exponential backoff
- Handle rate limits gracefully
- Parse JSON responses safely
For each feature: For each feature:
1. Write tests first 1. Write tests first using mocked API calls
2. Implement the feature 2. Implement the feature
3. Run: npm run build && npm run test && npm run lint 3. Verify: npm run build && npm run test && npm run lint
4. If all pass, update prd.json by setting passes to true for completed features 4. Update prd.json when feature passes
5. Commit with descriptive message 5. Commit and log progress
6. Append progress to progress.txt
When ALL Phase 1 features in prd.json have passes set to true: When ALL Phase 2 features pass:
Output <promise>PHASE_1_COMPLETE</promise> Output <promise>PHASE_2_COMPLETE</promise>
If blocked after 10 attempts on same issue: If blocked:
Document in progress.txt and output <promise>PHASE_1_BLOCKED</promise> Output <promise>PHASE_2_BLOCKED</promise>

View File

@@ -26,7 +26,7 @@
"name": "Claude API Client", "name": "Claude API Client",
"description": "Anthropic SDK integration with retry logic", "description": "Anthropic SDK integration with retry logic",
"priority": 1, "priority": 1,
"passes": false, "passes": true,
"acceptance": "Can send prompt to Claude and receive response, retries on failure" "acceptance": "Can send prompt to Claude and receive response, retries on failure"
}, },
{ {
@@ -35,7 +35,7 @@
"name": "Perplexity API Client", "name": "Perplexity API Client",
"description": "Perplexity REST API integration", "description": "Perplexity REST API integration",
"priority": 2, "priority": 2,
"passes": false, "passes": true,
"acceptance": "Can search Perplexity and parse response with sources" "acceptance": "Can search Perplexity and parse response with sources"
}, },
{ {
@@ -44,7 +44,7 @@
"name": "Architecture Classification", "name": "Architecture Classification",
"description": "Claude prompt for classifying app type", "description": "Claude prompt for classifying app type",
"priority": 3, "priority": 3,
"passes": false, "passes": true,
"acceptance": "Given idea text, returns valid Architecture JSON" "acceptance": "Given idea text, returns valid Architecture JSON"
}, },
{ {
@@ -53,7 +53,7 @@
"name": "Research Query Generation", "name": "Research Query Generation",
"description": "Generate Perplexity queries from architecture", "description": "Generate Perplexity queries from architecture",
"priority": 4, "priority": 4,
"passes": false, "passes": true,
"acceptance": "Given architecture, generates 4-6 relevant search queries" "acceptance": "Given architecture, generates 4-6 relevant search queries"
}, },
{ {

View File

@@ -18,3 +18,24 @@
* ralph-vibe init/new/validate/research exist * ralph-vibe init/new/validate/research exist
* ralph-vibe new fails gracefully without keys * ralph-vibe new fails gracefully without keys
[2026-01-10T12:18:00Z] [2] [COMPLETE] - Phase 2 Core API Integration implemented
- Claude API client with Anthropic SDK
* Retry logic with exponential backoff (3 attempts)
* Handles auth errors, rate limits, server errors
* Architecture classification with JSON parsing
* Research query generation
- Perplexity API client with native fetch
* Retry logic with exponential backoff
* Response parsing with sources and citations
* Research queries execution
- Prompt templates for architecture classification
- Default query generation for fallback
- ArchitectureGenerator for full pipeline
- 80 tests passing, all lint checks pass
- All Phase 2 acceptance criteria met:
* Can send prompt to Claude and receive response
* Retries on failure with exponential backoff
* Can search Perplexity and parse response with sources
* Given idea text, returns valid Architecture JSON
* Given architecture, generates 4-6 relevant search queries

View File

@@ -0,0 +1,172 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Config } from '../types/index.js';
// Create mock functions
const mockClassifyArchitecture = vi.fn();
const mockGenerateResearchQueries = vi.fn();
const mockResearchQueries = vi.fn();
// Mock the clients as classes
vi.mock('../clients/claude.js', () => ({
ClaudeClient: class MockClaudeClient {
classifyArchitecture = mockClassifyArchitecture;
generateResearchQueries = mockGenerateResearchQueries;
},
}));
vi.mock('../clients/perplexity.js', () => ({
PerplexityClient: class MockPerplexityClient {
researchQueries = mockResearchQueries;
},
}));
// Import after mocking
import { ArchitectureGenerator } from '../generators/architecture.js';
describe('ArchitectureGenerator', () => {
const mockConfig: Config = {
claudeApiKey: 'claude-test-key',
perplexityApiKey: 'perplexity-test-key',
};
const mockArchitecture = {
appType: 'cli' as const,
appTypeReason: 'Command-line tool',
interfaces: ['cli_args' as const],
interfacesReason: 'CLI arguments',
persistence: 'file_based' as const,
persistenceReason: 'File storage',
deployment: 'package_registry' as const,
deploymentReason: 'npm',
suggestedTechStack: {
language: 'TypeScript',
framework: 'Commander.js',
reasoning: 'CLI framework',
},
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('constructor', () => {
it('throws error when Claude API key is missing', () => {
const configWithoutClaude: Config = {
perplexityApiKey: 'key',
};
expect(() => new ArchitectureGenerator({ config: configWithoutClaude }))
.toThrow('Claude API key is required');
});
it('creates generator with valid config', () => {
expect(() => new ArchitectureGenerator({ config: mockConfig }))
.not.toThrow();
});
it('creates generator without Perplexity when skipResearch is true', () => {
const configWithoutPerplexity: Config = {
claudeApiKey: 'key',
};
expect(() => new ArchitectureGenerator({
config: configWithoutPerplexity,
skipResearch: true,
})).not.toThrow();
});
});
describe('classifyIdea', () => {
it('calls Claude client to classify idea', async () => {
mockClassifyArchitecture.mockResolvedValue(mockArchitecture);
const generator = new ArchitectureGenerator({ config: mockConfig });
const result = await generator.classifyIdea('A CLI tool for converting files');
expect(mockClassifyArchitecture).toHaveBeenCalledWith('A CLI tool for converting files');
expect(result).toEqual(mockArchitecture);
});
});
describe('generateResearchQueries', () => {
it('uses Claude to generate queries', async () => {
const mockQueries = ['query1', 'query2', 'query3', 'query4'];
mockGenerateResearchQueries.mockResolvedValue(mockQueries);
const generator = new ArchitectureGenerator({ config: mockConfig });
const result = await generator.generateResearchQueries(mockArchitecture);
expect(mockGenerateResearchQueries).toHaveBeenCalledWith(mockArchitecture);
expect(result).toEqual(mockQueries);
});
it('falls back to default queries when Claude fails', async () => {
mockGenerateResearchQueries.mockRejectedValue(new Error('API error'));
const generator = new ArchitectureGenerator({ config: mockConfig });
const result = await generator.generateResearchQueries(mockArchitecture);
// Should return default queries
expect(result.length).toBeGreaterThanOrEqual(4);
expect(result.some(q => q.toLowerCase().includes('cli'))).toBe(true);
});
});
describe('conductResearch', () => {
it('uses Perplexity to research queries', async () => {
const mockResearch = {
queries: ['q1', 'q2'],
results: [
{ answer: 'Answer 1', sources: [], citations: [] },
{ answer: 'Answer 2', sources: [], citations: [] },
],
summary: 'Research summary',
};
mockResearchQueries.mockResolvedValue(mockResearch);
const generator = new ArchitectureGenerator({ config: mockConfig });
const result = await generator.conductResearch(['q1', 'q2']);
expect(mockResearchQueries).toHaveBeenCalledWith(['q1', 'q2']);
expect(result).toEqual(mockResearch);
});
it('returns empty research when Perplexity is not available', async () => {
const configWithoutPerplexity: Config = {
claudeApiKey: 'key',
};
const generator = new ArchitectureGenerator({
config: configWithoutPerplexity,
skipResearch: true,
});
const result = await generator.conductResearch(['q1', 'q2']);
expect(result.results).toHaveLength(0);
expect(result.summary).toBe('Research was skipped.');
});
});
describe('analyzeAndResearch', () => {
it('performs full analysis and research pipeline', async () => {
const mockQueries = ['query1', 'query2', 'query3', 'query4'];
const mockResearch = {
queries: mockQueries,
results: [{ answer: 'Research result', sources: [], citations: [] }],
summary: 'Summary',
};
mockClassifyArchitecture.mockResolvedValue(mockArchitecture);
mockGenerateResearchQueries.mockResolvedValue(mockQueries);
mockResearchQueries.mockResolvedValue(mockResearch);
const generator = new ArchitectureGenerator({ config: mockConfig });
const result = await generator.analyzeAndResearch('A CLI tool for files');
expect(result.architecture).toEqual(mockArchitecture);
expect(result.queries).toEqual(mockQueries);
expect(result.research).toEqual(mockResearch);
});
});
});

View File

@@ -0,0 +1,199 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Create a mock for the messages.create function
const mockCreate = vi.fn();
// Mock the Anthropic SDK as a class
vi.mock('@anthropic-ai/sdk', () => {
return {
default: class MockAnthropic {
messages = {
create: mockCreate,
};
},
};
});
// Import after mocking
import { ClaudeClient } from '../clients/claude.js';
describe('ClaudeClient', () => {
let client: ClaudeClient;
beforeEach(() => {
vi.clearAllMocks();
client = new ClaudeClient({ apiKey: 'test-key' });
});
describe('analyze', () => {
it('returns text response from Claude', async () => {
mockCreate.mockResolvedValueOnce({
content: [{ type: 'text', text: 'Hello, world!' }],
});
const result = await client.analyze('test prompt');
expect(result).toBe('Hello, world!');
});
it('throws error when no text block in response', async () => {
mockCreate.mockResolvedValueOnce({
content: [],
});
await expect(client.analyze('test prompt')).rejects.toThrow('No text response from Claude');
});
it('throws error for invalid API key', async () => {
const authError = new Error('Unauthorized') as Error & { status: number };
authError.status = 401;
mockCreate.mockRejectedValueOnce(authError);
await expect(client.analyze('test prompt')).rejects.toThrow('Invalid Claude API key');
});
it('retries on server errors', async () => {
const serverError = new Error('Server Error') as Error & { status: number };
serverError.status = 500;
mockCreate
.mockRejectedValueOnce(serverError)
.mockRejectedValueOnce(serverError)
.mockResolvedValueOnce({
content: [{ type: 'text', text: 'Success after retry' }],
});
const result = await client.analyze('test prompt');
expect(result).toBe('Success after retry');
expect(mockCreate).toHaveBeenCalledTimes(3);
});
});
describe('classifyArchitecture', () => {
it('parses valid architecture JSON', async () => {
const architectureJson = JSON.stringify({
appType: 'cli',
appTypeReason: 'Command-line tool',
interfaces: ['cli_args'],
interfacesReason: 'CLI arguments',
persistence: 'file_based',
persistenceReason: 'File storage',
deployment: 'package_registry',
deploymentReason: 'npm',
suggestedTechStack: {
language: 'TypeScript',
framework: 'Commander.js',
reasoning: 'CLI framework',
},
});
mockCreate.mockResolvedValueOnce({
content: [{ type: 'text', text: architectureJson }],
});
const result = await client.classifyArchitecture('A CLI tool for converting files');
expect(result.appType).toBe('cli');
expect(result.interfaces).toContain('cli_args');
expect(result.persistence).toBe('file_based');
expect(result.deployment).toBe('package_registry');
});
it('handles JSON wrapped in markdown code blocks', async () => {
const architectureJson = `\`\`\`json
{
"appType": "web",
"appTypeReason": "Web app",
"interfaces": ["rest_api"],
"interfacesReason": "REST API",
"persistence": "remote_db",
"persistenceReason": "Database",
"deployment": "cloud",
"deploymentReason": "Cloud deployment",
"suggestedTechStack": {
"language": "TypeScript",
"framework": "Express",
"reasoning": "Node.js backend"
}
}
\`\`\``;
mockCreate.mockResolvedValueOnce({
content: [{ type: 'text', text: architectureJson }],
});
const result = await client.classifyArchitecture('A web application');
expect(result.appType).toBe('web');
expect(result.interfaces).toContain('rest_api');
});
it('throws error for invalid JSON', async () => {
mockCreate.mockResolvedValueOnce({
content: [{ type: 'text', text: 'not valid json' }],
});
await expect(client.classifyArchitecture('test')).rejects.toThrow('Failed to parse architecture');
});
it('throws error for missing required fields', async () => {
mockCreate.mockResolvedValueOnce({
content: [{ type: 'text', text: '{"appType": "web"}' }],
});
await expect(client.classifyArchitecture('test')).rejects.toThrow('Missing required fields');
});
});
describe('generateResearchQueries', () => {
const mockArchitecture = {
appType: 'cli' as const,
appTypeReason: 'Command-line tool',
interfaces: ['cli_args' as const],
interfacesReason: 'CLI arguments',
persistence: 'file_based' as const,
persistenceReason: 'File storage',
deployment: 'package_registry' as const,
deploymentReason: 'npm',
suggestedTechStack: {
language: 'TypeScript',
framework: 'Commander.js',
reasoning: 'CLI framework',
},
};
it('parses valid query array', async () => {
const queries = ['query1', 'query2', 'query3', 'query4'];
mockCreate.mockResolvedValueOnce({
content: [{ type: 'text', text: JSON.stringify(queries) }],
});
const result = await client.generateResearchQueries(mockArchitecture);
expect(result).toEqual(queries);
expect(result.length).toBe(4);
});
it('handles JSON wrapped in markdown code blocks', async () => {
const queriesJson = `\`\`\`json
["query1", "query2", "query3", "query4"]
\`\`\``;
mockCreate.mockResolvedValueOnce({
content: [{ type: 'text', text: queriesJson }],
});
const result = await client.generateResearchQueries(mockArchitecture);
expect(result).toEqual(['query1', 'query2', 'query3', 'query4']);
});
it('throws error for non-array response', async () => {
mockCreate.mockResolvedValueOnce({
content: [{ type: 'text', text: '{"not": "an array"}' }],
});
await expect(client.generateResearchQueries(mockArchitecture)).rejects.toThrow('not an array');
});
});
});

View File

@@ -0,0 +1,235 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { PerplexityClient } from '../clients/perplexity.js';
describe('PerplexityClient', () => {
let client: PerplexityClient;
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
mockFetch = vi.fn();
global.fetch = mockFetch;
client = new PerplexityClient({ apiKey: 'test-key' });
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('search', () => {
it('returns parsed search result', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 'test-id',
model: 'sonar-pro',
choices: [
{
index: 0,
message: {
role: 'assistant',
content: 'This is the answer to your query.',
},
finish_reason: 'stop',
},
],
citations: ['https://example.com/source1', 'https://example.com/source2'],
}),
});
const result = await client.search('What is TypeScript?');
expect(result.answer).toBe('This is the answer to your query.');
expect(result.citations).toHaveLength(2);
expect(result.sources).toHaveLength(2);
expect(result.sources[0].url).toBe('https://example.com/source1');
});
it('handles response without citations', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 'test-id',
model: 'sonar-pro',
choices: [
{
index: 0,
message: {
role: 'assistant',
content: 'Answer without citations.',
},
finish_reason: 'stop',
},
],
}),
});
const result = await client.search('Simple query');
expect(result.answer).toBe('Answer without citations.');
expect(result.citations).toHaveLength(0);
expect(result.sources).toHaveLength(0);
});
it('throws error for invalid API key', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 401,
statusText: 'Unauthorized',
});
await expect(client.search('test')).rejects.toThrow('Invalid Perplexity API key');
});
it('retries on server errors', async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Internal Server Error',
})
.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Internal Server Error',
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 'test-id',
model: 'sonar-pro',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'Success!' },
finish_reason: 'stop',
},
],
}),
});
const result = await client.search('test');
expect(result.answer).toBe('Success!');
expect(mockFetch).toHaveBeenCalledTimes(3);
});
it('throws error when no choices in response', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 'test-id',
model: 'sonar-pro',
choices: [],
}),
});
await expect(client.search('test')).rejects.toThrow('No response from Perplexity');
});
it('sends correct request format', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 'test-id',
model: 'sonar-pro',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'Response' },
finish_reason: 'stop',
},
],
}),
});
await client.search('test query');
expect(mockFetch).toHaveBeenCalledWith(
'https://api.perplexity.ai/chat/completions',
expect.objectContaining({
method: 'POST',
headers: {
'Authorization': 'Bearer test-key',
'Content-Type': 'application/json',
},
})
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.model).toBe('sonar-pro');
expect(body.messages).toHaveLength(2);
expect(body.messages[1].content).toBe('test query');
});
});
describe('researchQueries', () => {
it('returns research with multiple query results', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({
id: '1',
model: 'sonar-pro',
choices: [{ index: 0, message: { role: 'assistant', content: 'Answer 1' }, finish_reason: 'stop' }],
citations: ['https://source1.com'],
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
id: '2',
model: 'sonar-pro',
choices: [{ index: 0, message: { role: 'assistant', content: 'Answer 2' }, finish_reason: 'stop' }],
citations: ['https://source2.com'],
}),
});
const queries = ['query1', 'query2'];
const result = await client.researchQueries(queries);
expect(result.queries).toEqual(queries);
expect(result.results).toHaveLength(2);
expect(result.results[0].answer).toBe('Answer 1');
expect(result.results[1].answer).toBe('Answer 2');
expect(result.summary).toContain('Answer 1');
expect(result.summary).toContain('Answer 2');
});
it('continues when individual queries fail after retries', async () => {
// First query fails all 3 retries, second succeeds
mockFetch
.mockResolvedValueOnce({ ok: false, status: 500, statusText: 'Error' })
.mockResolvedValueOnce({ ok: false, status: 500, statusText: 'Error' })
.mockResolvedValueOnce({ ok: false, status: 500, statusText: 'Error' })
.mockResolvedValueOnce({
ok: true,
json: async () => ({
id: '2',
model: 'sonar-pro',
choices: [{ index: 0, message: { role: 'assistant', content: 'Success' }, finish_reason: 'stop' }],
}),
});
const result = await client.researchQueries(['fail-query', 'success-query']);
expect(result.results).toHaveLength(1);
expect(result.results[0].answer).toBe('Success');
}, 10000);
it('returns empty results when all queries fail', async () => {
// All retries fail for both queries (3 retries each = 6 calls)
for (let i = 0; i < 6; i++) {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Error',
});
}
const result = await client.researchQueries(['fail1', 'fail2']);
expect(result.results).toHaveLength(0);
expect(result.summary).toBe('No research results available.');
}, 15000);
});
});

View File

@@ -0,0 +1,235 @@
import { describe, it, expect } from 'vitest';
import {
buildArchitectureClassificationPrompt,
buildResearchQueryPrompt,
generateDefaultResearchQueries,
} from '../prompts/templates.js';
import type { Architecture } from '../types/index.js';
describe('Prompt Templates', () => {
describe('buildArchitectureClassificationPrompt', () => {
it('includes the user idea in the prompt', () => {
const idea = 'A CLI tool that converts markdown to PDF';
const prompt = buildArchitectureClassificationPrompt(idea);
expect(prompt).toContain('<idea>');
expect(prompt).toContain(idea);
expect(prompt).toContain('</idea>');
});
it('includes all app type options', () => {
const prompt = buildArchitectureClassificationPrompt('test');
expect(prompt).toContain('web:');
expect(prompt).toContain('desktop:');
expect(prompt).toContain('cli:');
expect(prompt).toContain('library:');
expect(prompt).toContain('mobile:');
expect(prompt).toContain('script:');
});
it('includes all interface type options', () => {
const prompt = buildArchitectureClassificationPrompt('test');
expect(prompt).toContain('rest_api:');
expect(prompt).toContain('graphql:');
expect(prompt).toContain('ipc:');
expect(prompt).toContain('cli_args:');
expect(prompt).toContain('websocket:');
});
it('includes all persistence options', () => {
const prompt = buildArchitectureClassificationPrompt('test');
expect(prompt).toContain('remote_db:');
expect(prompt).toContain('local_db:');
expect(prompt).toContain('file_based:');
expect(prompt).toContain('in_memory:');
});
it('includes all deployment options', () => {
const prompt = buildArchitectureClassificationPrompt('test');
expect(prompt).toContain('cloud:');
expect(prompt).toContain('self_hosted:');
expect(prompt).toContain('package_registry:');
expect(prompt).toContain('app_store:');
});
it('requests JSON output format', () => {
const prompt = buildArchitectureClassificationPrompt('test');
expect(prompt).toContain('Output ONLY valid JSON');
expect(prompt).toContain('"appType"');
expect(prompt).toContain('"interfaces"');
expect(prompt).toContain('"persistence"');
expect(prompt).toContain('"deployment"');
});
});
describe('buildResearchQueryPrompt', () => {
const mockArchitecture: Architecture = {
appType: 'cli',
appTypeReason: 'Command-line tool',
interfaces: ['cli_args'],
interfacesReason: 'CLI arguments',
persistence: 'file_based',
persistenceReason: 'File storage',
deployment: 'package_registry',
deploymentReason: 'npm',
suggestedTechStack: {
language: 'TypeScript',
framework: 'Commander.js',
reasoning: 'CLI framework',
},
};
it('includes the architecture JSON', () => {
const prompt = buildResearchQueryPrompt(mockArchitecture);
expect(prompt).toContain('<architecture>');
expect(prompt).toContain('"appType": "cli"');
expect(prompt).toContain('</architecture>');
});
it('requests 4-6 search queries', () => {
const prompt = buildResearchQueryPrompt(mockArchitecture);
expect(prompt).toContain('4-6 search queries');
});
it('mentions key research areas', () => {
const prompt = buildResearchQueryPrompt(mockArchitecture);
expect(prompt).toContain('best practices');
expect(prompt).toContain('Common mistakes');
expect(prompt).toContain('Recommended libraries');
expect(prompt).toContain('Testing approaches');
});
it('requests JSON array output', () => {
const prompt = buildResearchQueryPrompt(mockArchitecture);
expect(prompt).toContain('JSON array of query strings');
});
});
describe('generateDefaultResearchQueries', () => {
it('generates queries for CLI apps', () => {
const architecture: Architecture = {
appType: 'cli',
appTypeReason: 'CLI tool',
interfaces: ['cli_args'],
interfacesReason: 'CLI',
persistence: 'file_based',
persistenceReason: 'Files',
deployment: 'package_registry',
deploymentReason: 'npm',
suggestedTechStack: {
language: 'TypeScript',
framework: 'Commander.js',
reasoning: 'CLI framework',
},
};
const queries = generateDefaultResearchQueries(architecture);
expect(queries.length).toBeGreaterThanOrEqual(4);
expect(queries.some(q => q.toLowerCase().includes('cli'))).toBe(true);
expect(queries.some(q => q.toLowerCase().includes('typescript'))).toBe(true);
});
it('generates queries for web apps', () => {
const architecture: Architecture = {
appType: 'web',
appTypeReason: 'Web app',
interfaces: ['rest_api'],
interfacesReason: 'REST',
persistence: 'remote_db',
persistenceReason: 'Database',
deployment: 'cloud',
deploymentReason: 'Cloud',
suggestedTechStack: {
language: 'TypeScript',
framework: 'Express',
reasoning: 'Backend',
},
};
const queries = generateDefaultResearchQueries(architecture);
expect(queries.length).toBeGreaterThanOrEqual(4);
expect(queries.some(q => q.toLowerCase().includes('web'))).toBe(true);
expect(queries.some(q => q.toLowerCase().includes('express'))).toBe(true);
});
it('generates queries for desktop apps', () => {
const architecture: Architecture = {
appType: 'desktop',
appTypeReason: 'Desktop app',
interfaces: ['ipc'],
interfacesReason: 'IPC',
persistence: 'local_db',
persistenceReason: 'SQLite',
deployment: 'desktop_installer',
deploymentReason: 'Installer',
suggestedTechStack: {
language: 'TypeScript',
framework: 'Tauri',
reasoning: 'Desktop framework',
},
};
const queries = generateDefaultResearchQueries(architecture);
expect(queries.length).toBeGreaterThanOrEqual(4);
expect(queries.some(q => q.toLowerCase().includes('tauri'))).toBe(true);
});
it('generates queries for library packages', () => {
const architecture: Architecture = {
appType: 'library',
appTypeReason: 'Library',
interfaces: ['module'],
interfacesReason: 'Module',
persistence: 'in_memory',
persistenceReason: 'None',
deployment: 'package_registry',
deploymentReason: 'npm',
suggestedTechStack: {
language: 'TypeScript',
framework: '',
reasoning: 'Library',
},
};
const queries = generateDefaultResearchQueries(architecture);
expect(queries.length).toBeGreaterThanOrEqual(4);
expect(queries.some(q => q.toLowerCase().includes('library'))).toBe(true);
});
it('includes current year in queries', () => {
const architecture: Architecture = {
appType: 'web',
appTypeReason: 'Web',
interfaces: ['rest_api'],
interfacesReason: 'REST',
persistence: 'remote_db',
persistenceReason: 'DB',
deployment: 'cloud',
deploymentReason: 'Cloud',
suggestedTechStack: {
language: 'TypeScript',
framework: 'Express',
reasoning: 'Backend',
},
};
const queries = generateDefaultResearchQueries(architecture);
const currentYear = new Date().getFullYear().toString();
expect(queries.some(q => q.includes(currentYear))).toBe(true);
});
});
});

274
src/clients/claude.ts Normal file
View File

@@ -0,0 +1,274 @@
import Anthropic from '@anthropic-ai/sdk';
import { logger } from '../utils/logger.js';
import type { Architecture, Specification, PRDOutput, ValidationResult, Research } from '../types/index.js';
const MAX_RETRIES = 3;
const INITIAL_BACKOFF_MS = 1000;
export interface ClaudeClientOptions {
apiKey: string;
model?: string;
}
export class ClaudeClient {
private client: Anthropic;
private model: string;
constructor(options: ClaudeClientOptions) {
this.client = new Anthropic({ apiKey: options.apiKey });
this.model = options.model || 'claude-sonnet-4-20250514';
}
private async retryWithBackoff<T>(
operation: () => Promise<T>,
operationName: string
): Promise<T> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
return await operation();
} catch (err) {
const error = err as Error & { status?: number };
lastError = error;
// Don't retry on auth errors
if (error.status === 401) {
throw new Error('Invalid Claude API key. Run `ralph-vibe init` to configure.');
}
// Handle rate limits
if (error.status === 429) {
const waitTime = INITIAL_BACKOFF_MS * Math.pow(2, attempt);
logger.warn(`Rate limited. Waiting ${waitTime}ms before retry ${attempt}/${MAX_RETRIES}...`);
await this.sleep(waitTime);
continue;
}
// Retry on server errors
if (error.status && error.status >= 500) {
const waitTime = INITIAL_BACKOFF_MS * Math.pow(2, attempt - 1);
logger.debug(`${operationName} failed (attempt ${attempt}/${MAX_RETRIES}): ${error.message}`);
if (attempt < MAX_RETRIES) {
await this.sleep(waitTime);
}
continue;
}
// Don't retry on other errors
throw error;
}
}
throw lastError || new Error(`${operationName} failed after ${MAX_RETRIES} attempts`);
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
async analyze(prompt: string): Promise<string> {
return this.retryWithBackoff(async () => {
const response = await this.client.messages.create({
model: this.model,
max_tokens: 4096,
messages: [{ role: 'user', content: prompt }],
});
const textBlock = response.content.find(block => block.type === 'text');
if (!textBlock || textBlock.type !== 'text') {
throw new Error('No text response from Claude');
}
return textBlock.text;
}, 'analyze');
}
async classifyArchitecture(idea: string): Promise<Architecture> {
const prompt = `Analyze this app idea and classify its architecture:
<idea>
${idea}
</idea>
Determine:
1. APPLICATION TYPE (choose one):
- web: Frontend + backend web application
- desktop: Native or Electron/Tauri desktop application
- cli: Command-line tool
- library: Reusable package/module
- mobile: iOS/Android application
- script: Automation/utility script
- other: Specify
2. INTERFACE TYPES (choose all that apply):
- rest_api: RESTful HTTP endpoints
- graphql: GraphQL API
- ipc: Inter-process communication (desktop)
- module: Internal module boundaries (library)
- cli_args: Command-line arguments/flags
- file_format: Custom file format handling
- websocket: Real-time communication
- grpc: gRPC services
3. PERSISTENCE (choose one):
- remote_db: Cloud/remote database (PostgreSQL, MySQL, MongoDB)
- local_db: Local database (SQLite, LevelDB)
- file_based: File system storage
- in_memory: No persistence
- cloud_storage: Object storage (S3, etc.)
4. DEPLOYMENT (choose one):
- cloud: Cloud platform (Vercel, AWS, GCP, etc.)
- self_hosted: Self-managed server
- desktop_installer: Native installers
- package_registry: npm, PyPI, crates.io, etc.
- app_store: Mobile app stores
- none: No deployment (local script)
Output ONLY valid JSON (no markdown, no explanation):
{
"appType": "...",
"appTypeReason": "...",
"interfaces": ["..."],
"interfacesReason": "...",
"persistence": "...",
"persistenceReason": "...",
"deployment": "...",
"deploymentReason": "...",
"suggestedTechStack": {
"language": "...",
"framework": "...",
"reasoning": "..."
}
}`;
const response = await this.analyze(prompt);
return this.parseArchitectureResponse(response);
}
private parseArchitectureResponse(response: string): Architecture {
// Extract JSON from response (handle markdown code blocks)
let jsonStr = response.trim();
// Remove markdown code blocks if present
const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
if (jsonMatch) {
jsonStr = jsonMatch[1].trim();
}
try {
const parsed = JSON.parse(jsonStr);
// Validate required fields
if (!parsed.appType || !parsed.interfaces || !parsed.persistence || !parsed.deployment) {
throw new Error('Missing required fields in architecture response');
}
return parsed as Architecture;
} catch (err) {
const error = err as Error;
logger.debug(`Failed to parse architecture response: ${response}`);
throw new Error(`Failed to parse architecture classification: ${error.message}`);
}
}
async generateResearchQueries(architecture: Architecture): Promise<string[]> {
const prompt = `Based on this architecture classification, generate Perplexity search queries
to research current best practices:
<architecture>
${JSON.stringify(architecture, null, 2)}
</architecture>
Generate 4-6 search queries that will find:
1. Project structure and architecture best practices for this stack
2. Common mistakes and pitfalls to avoid
3. Recommended libraries and tools (current year 2026)
4. Testing approaches and frameworks
5. (If applicable) Security considerations
6. (If applicable) Performance optimization
Output ONLY a valid JSON array of query strings (no markdown, no explanation):
["query1", "query2", ...]`;
const response = await this.analyze(prompt);
return this.parseQueriesResponse(response);
}
private parseQueriesResponse(response: string): string[] {
let jsonStr = response.trim();
// Remove markdown code blocks if present
const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
if (jsonMatch) {
jsonStr = jsonMatch[1].trim();
}
try {
const parsed = JSON.parse(jsonStr);
if (!Array.isArray(parsed)) {
throw new Error('Response is not an array');
}
if (parsed.length < 4 || parsed.length > 6) {
logger.warn(`Expected 4-6 queries, got ${parsed.length}`);
}
return parsed.map(q => String(q));
} catch (err) {
const error = err as Error;
logger.debug(`Failed to parse queries response: ${response}`);
throw new Error(`Failed to parse research queries: ${error.message}`);
}
}
async generateSpec(
idea: string,
architecture: Architecture,
research: Research
): Promise<Specification> {
// This will be fully implemented in Phase 3
const prompt = `Generate a specification for this application:
<idea>
${idea}
</idea>
<architecture>
${JSON.stringify(architecture, null, 2)}
</architecture>
<research>
${research.summary}
</research>
Output a specification with features, data models, interfaces, and tech stack as JSON.`;
const response = await this.analyze(prompt);
// Basic parsing - full implementation in Phase 3
try {
let jsonStr = response.trim();
const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
if (jsonMatch) {
jsonStr = jsonMatch[1].trim();
}
return JSON.parse(jsonStr) as Specification;
} catch {
throw new Error('Failed to parse specification response');
}
}
async generatePRD(_spec: Specification): Promise<PRDOutput> {
// Placeholder - full implementation in Phase 3
throw new Error('PRD generation not yet implemented');
}
async validate(_prompt: string): Promise<ValidationResult> {
// Placeholder - can use Claude for advanced validation in Phase 4
throw new Error('Claude-based validation not yet implemented');
}
}

232
src/clients/perplexity.ts Normal file
View File

@@ -0,0 +1,232 @@
import { logger } from '../utils/logger.js';
import type { SearchResult, Research, TechStack } from '../types/index.js';
const MAX_RETRIES = 3;
const INITIAL_BACKOFF_MS = 1000;
const API_BASE_URL = 'https://api.perplexity.ai';
export interface PerplexityClientOptions {
apiKey: string;
model?: string;
}
interface PerplexityMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
interface PerplexityResponse {
id: string;
model: string;
choices: {
index: number;
message: {
role: string;
content: string;
};
finish_reason: string;
}[];
citations?: string[];
}
export class PerplexityClient {
private apiKey: string;
private model: string;
constructor(options: PerplexityClientOptions) {
this.apiKey = options.apiKey;
this.model = options.model || 'sonar-pro';
}
private async retryWithBackoff<T>(
operation: () => Promise<T>,
operationName: string
): Promise<T> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
return await operation();
} catch (err) {
const error = err as Error & { status?: number };
lastError = error;
// Don't retry on auth errors
if (error.status === 401) {
throw new Error('Invalid Perplexity API key. Run `ralph-vibe init` to configure.');
}
// Handle rate limits
if (error.status === 429) {
const waitTime = INITIAL_BACKOFF_MS * Math.pow(2, attempt);
logger.warn(`Rate limited. Waiting ${waitTime}ms before retry ${attempt}/${MAX_RETRIES}...`);
await this.sleep(waitTime);
continue;
}
// Retry on server errors
if (error.status && error.status >= 500) {
const waitTime = INITIAL_BACKOFF_MS * Math.pow(2, attempt - 1);
logger.debug(`${operationName} failed (attempt ${attempt}/${MAX_RETRIES}): ${error.message}`);
if (attempt < MAX_RETRIES) {
await this.sleep(waitTime);
}
continue;
}
// Don't retry on other errors
throw error;
}
}
throw lastError || new Error(`${operationName} failed after ${MAX_RETRIES} attempts`);
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
async search(query: string): Promise<SearchResult> {
return this.retryWithBackoff(async () => {
const messages: PerplexityMessage[] = [
{
role: 'system',
content: 'You are a helpful research assistant. Provide concise, factual answers with sources.',
},
{
role: 'user',
content: query,
},
];
const response = await fetch(`${API_BASE_URL}/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: this.model,
messages,
max_tokens: 2048,
}),
});
if (!response.ok) {
const error = new Error(`Perplexity API error: ${response.status} ${response.statusText}`) as Error & { status: number };
error.status = response.status;
throw error;
}
const data = await response.json() as PerplexityResponse;
if (!data.choices || data.choices.length === 0) {
throw new Error('No response from Perplexity');
}
const answer = data.choices[0].message.content;
const citations = data.citations || [];
// Parse sources from citations
const sources = citations.map((url, index) => ({
title: `Source ${index + 1}`,
url,
}));
return {
answer,
sources,
citations,
};
}, 'search');
}
async researchStack(stack: TechStack): Promise<Research> {
const queries = this.generateStackQueries(stack);
const results: SearchResult[] = [];
for (const query of queries) {
logger.debug(`Researching: ${query}`);
try {
const result = await this.search(query);
results.push(result);
} catch (err) {
const error = err as Error;
logger.warn(`Research query failed: ${error.message}`);
// Continue with other queries
}
}
// Generate summary from all results
const summary = this.summarizeResults(results);
return {
queries,
results,
summary,
};
}
async researchQueries(queries: string[]): Promise<Research> {
const results: SearchResult[] = [];
for (const query of queries) {
logger.debug(`Researching: ${query}`);
try {
const result = await this.search(query);
results.push(result);
} catch (err) {
const error = err as Error;
logger.warn(`Research query failed: ${error.message}`);
// Continue with other queries
}
}
// Generate summary from all results
const summary = this.summarizeResults(results);
return {
queries,
results,
summary,
};
}
private generateStackQueries(stack: TechStack): string[] {
const year = new Date().getFullYear();
const queries: string[] = [];
// Best practices query
if (stack.framework) {
queries.push(`${stack.language} ${stack.framework} project structure best practices ${year}`);
} else {
queries.push(`${stack.language} project structure best practices ${year}`);
}
// Common mistakes query
queries.push(`${stack.language} ${stack.framework || ''} common mistakes pitfalls to avoid`.trim());
// Recommended libraries query
queries.push(`${stack.language} recommended libraries ${year} ${stack.libraries.slice(0, 2).join(' ')}`.trim());
// Testing query
queries.push(`${stack.language} ${stack.testingFramework} testing best practices ${year}`);
return queries;
}
private summarizeResults(results: SearchResult[]): string {
if (results.length === 0) {
return 'No research results available.';
}
const summaryParts = results.map((result, index) => {
const sources = result.sources.length > 0
? `\n Sources: ${result.sources.map(s => s.url).join(', ')}`
: '';
return `### Finding ${index + 1}\n${result.answer}${sources}`;
});
return summaryParts.join('\n\n');
}
}

View File

@@ -0,0 +1,93 @@
import { ClaudeClient } from '../clients/claude.js';
import { PerplexityClient } from '../clients/perplexity.js';
import { generateDefaultResearchQueries } from '../prompts/templates.js';
import { logger } from '../utils/logger.js';
import type { Architecture, Research, Config } from '../types/index.js';
export interface ArchitectureGeneratorOptions {
config: Config;
skipResearch?: boolean;
}
export class ArchitectureGenerator {
private claudeClient: ClaudeClient;
private perplexityClient: PerplexityClient | null;
constructor(options: ArchitectureGeneratorOptions) {
if (!options.config.claudeApiKey) {
throw new Error('Claude API key is required');
}
this.claudeClient = new ClaudeClient({
apiKey: options.config.claudeApiKey,
});
if (options.config.perplexityApiKey && !options.skipResearch) {
this.perplexityClient = new PerplexityClient({
apiKey: options.config.perplexityApiKey,
});
} else {
this.perplexityClient = null;
}
}
async classifyIdea(idea: string): Promise<Architecture> {
logger.info('Analyzing app idea...');
const architecture = await this.claudeClient.classifyArchitecture(idea);
logger.success('Architecture classification complete');
return architecture;
}
async generateResearchQueries(architecture: Architecture): Promise<string[]> {
logger.info('Generating research queries...');
try {
// Try to use Claude to generate smart queries
const queries = await this.claudeClient.generateResearchQueries(architecture);
logger.success(`Generated ${queries.length} research queries`);
return queries;
} catch (err) {
const error = err as Error;
logger.warn(`Claude query generation failed: ${error.message}`);
logger.info('Using default research queries...');
// Fall back to default queries
const queries = generateDefaultResearchQueries(architecture);
logger.success(`Generated ${queries.length} default research queries`);
return queries;
}
}
async conductResearch(queries: string[]): Promise<Research> {
if (!this.perplexityClient) {
logger.warn('Perplexity client not available, skipping research');
return {
queries,
results: [],
summary: 'Research was skipped.',
};
}
logger.info(`Conducting research with ${queries.length} queries...`);
const research = await this.perplexityClient.researchQueries(queries);
logger.success(`Research complete with ${research.results.length} findings`);
return research;
}
async analyzeAndResearch(idea: string): Promise<{
architecture: Architecture;
queries: string[];
research: Research;
}> {
// Step 1: Classify the idea
const architecture = await this.classifyIdea(idea);
// Step 2: Generate research queries
const queries = await this.generateResearchQueries(architecture);
// Step 3: Conduct research
const research = await this.conductResearch(queries);
return { architecture, queries, research };
}
}

151
src/prompts/templates.ts Normal file
View File

@@ -0,0 +1,151 @@
import type { Architecture } from '../types/index.js';
export const ARCHITECTURE_CLASSIFICATION_PROMPT = `Analyze this app idea and classify its architecture:
<idea>
{user_idea}
</idea>
Determine:
1. APPLICATION TYPE (choose one):
- web: Frontend + backend web application
- desktop: Native or Electron/Tauri desktop application
- cli: Command-line tool
- library: Reusable package/module
- mobile: iOS/Android application
- script: Automation/utility script
- other: Specify
2. INTERFACE TYPES (choose all that apply):
- rest_api: RESTful HTTP endpoints
- graphql: GraphQL API
- ipc: Inter-process communication (desktop)
- module: Internal module boundaries (library)
- cli_args: Command-line arguments/flags
- file_format: Custom file format handling
- websocket: Real-time communication
- grpc: gRPC services
3. PERSISTENCE (choose one):
- remote_db: Cloud/remote database (PostgreSQL, MySQL, MongoDB)
- local_db: Local database (SQLite, LevelDB)
- file_based: File system storage
- in_memory: No persistence
- cloud_storage: Object storage (S3, etc.)
4. DEPLOYMENT (choose one):
- cloud: Cloud platform (Vercel, AWS, GCP, etc.)
- self_hosted: Self-managed server
- desktop_installer: Native installers
- package_registry: npm, PyPI, crates.io, etc.
- app_store: Mobile app stores
- none: No deployment (local script)
Output ONLY valid JSON (no markdown, no explanation):
{
"appType": "...",
"appTypeReason": "...",
"interfaces": ["..."],
"interfacesReason": "...",
"persistence": "...",
"persistenceReason": "...",
"deployment": "...",
"deploymentReason": "...",
"suggestedTechStack": {
"language": "...",
"framework": "...",
"reasoning": "..."
}
}`;
export function buildArchitectureClassificationPrompt(idea: string): string {
return ARCHITECTURE_CLASSIFICATION_PROMPT.replace('{user_idea}', idea);
}
export const RESEARCH_QUERY_GENERATION_PROMPT = `Based on this architecture classification, generate Perplexity search queries
to research current best practices:
<architecture>
{architecture_json}
</architecture>
Generate 4-6 search queries that will find:
1. Project structure and architecture best practices for this stack
2. Common mistakes and pitfalls to avoid
3. Recommended libraries and tools (current year)
4. Testing approaches and frameworks
5. (If applicable) Security considerations
6. (If applicable) Performance optimization
Output ONLY a valid JSON array of query strings (no markdown, no explanation):
["query1", "query2", ...]`;
export function buildResearchQueryPrompt(architecture: Architecture): string {
return RESEARCH_QUERY_GENERATION_PROMPT.replace(
'{architecture_json}',
JSON.stringify(architecture, null, 2)
);
}
export function generateDefaultResearchQueries(architecture: Architecture): string[] {
const { appType, suggestedTechStack } = architecture;
const year = new Date().getFullYear();
const lang = suggestedTechStack.language;
const framework = suggestedTechStack.framework;
const queries: string[] = [];
// App type specific queries
switch (appType) {
case 'web':
queries.push(`${lang} ${framework} web application best practices ${year}`);
queries.push(`${framework} common security mistakes to avoid`);
queries.push(`${lang} web backend recommended libraries ${year} authentication database`);
queries.push(`${lang} ${framework} testing Jest Vitest comparison ${year}`);
break;
case 'desktop':
queries.push(`${framework} project structure best practices`);
queries.push(`${framework} common mistakes`);
queries.push(`${framework} recommended plugins`);
queries.push(`${framework} application testing approaches`);
break;
case 'cli':
queries.push(`${lang} CLI tool best practices argument parsing`);
queries.push(`CLI tool UX common mistakes`);
queries.push(`${lang} CLI framework comparison ${year}`);
queries.push(`CLI tool testing strategies`);
break;
case 'library':
queries.push(`${lang} library package best practices ${year}`);
queries.push(`${lang} npm package common mistakes`);
queries.push(`${lang} library testing and documentation ${year}`);
queries.push(`${lang} package bundling and tree shaking`);
break;
case 'mobile':
queries.push(`${framework} mobile app best practices ${year}`);
queries.push(`${framework} common performance mistakes`);
queries.push(`${framework} recommended libraries state management ${year}`);
queries.push(`${framework} mobile testing approaches`);
break;
case 'script':
queries.push(`${lang} automation script best practices`);
queries.push(`${lang} scripting common mistakes error handling`);
queries.push(`${lang} scripting libraries utilities ${year}`);
queries.push(`${lang} script testing`);
break;
default:
queries.push(`${lang} ${framework} best practices ${year}`);
queries.push(`${lang} common mistakes to avoid`);
queries.push(`${lang} recommended libraries ${year}`);
queries.push(`${lang} testing frameworks ${year}`);
}
return queries;
}