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:
@@ -1,37 +1,40 @@
|
||||
---
|
||||
active: true
|
||||
iteration: 1
|
||||
max_iterations: 30
|
||||
completion_promise: "PHASE_1_COMPLETE"
|
||||
started_at: "2026-01-10T12:00:45Z"
|
||||
max_iterations: 40
|
||||
completion_promise: "PHASE_2_COMPLETE"
|
||||
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 prd.json for feature tracking.
|
||||
Read CLAUDE.md for project configuration.
|
||||
Read PROMPT.md for API specifications.
|
||||
Read prd.json for Phase 2 features.
|
||||
|
||||
Phase 1 tasks:
|
||||
1. Set up TypeScript project with all dependencies
|
||||
2. Configure tsup, vitest, eslint
|
||||
3. Implement CLI framework with Commander.js
|
||||
4. Implement config management for API key storage
|
||||
5. Create basic project structure
|
||||
6. Write tests for config and CLI
|
||||
Phase 2 tasks:
|
||||
1. Claude API client with Anthropic SDK
|
||||
2. Perplexity API client with fetch
|
||||
3. Architecture classification prompt and parser
|
||||
4. Research query generation
|
||||
5. Tests for all API clients
|
||||
|
||||
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:
|
||||
1. Write tests first
|
||||
1. Write tests first using mocked API calls
|
||||
2. Implement the feature
|
||||
3. Run: npm run build && npm run test && npm run lint
|
||||
4. If all pass, update prd.json by setting passes to true for completed features
|
||||
5. Commit with descriptive message
|
||||
6. Append progress to progress.txt
|
||||
3. Verify: npm run build && npm run test && npm run lint
|
||||
4. Update prd.json when feature passes
|
||||
5. Commit and log progress
|
||||
|
||||
When ALL Phase 1 features in prd.json have passes set to true:
|
||||
Output <promise>PHASE_1_COMPLETE</promise>
|
||||
When ALL Phase 2 features pass:
|
||||
Output <promise>PHASE_2_COMPLETE</promise>
|
||||
|
||||
If blocked after 10 attempts on same issue:
|
||||
Document in progress.txt and output <promise>PHASE_1_BLOCKED</promise>
|
||||
If blocked:
|
||||
Output <promise>PHASE_2_BLOCKED</promise>
|
||||
|
||||
8
prd.json
8
prd.json
@@ -26,7 +26,7 @@
|
||||
"name": "Claude API Client",
|
||||
"description": "Anthropic SDK integration with retry logic",
|
||||
"priority": 1,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"acceptance": "Can send prompt to Claude and receive response, retries on failure"
|
||||
},
|
||||
{
|
||||
@@ -35,7 +35,7 @@
|
||||
"name": "Perplexity API Client",
|
||||
"description": "Perplexity REST API integration",
|
||||
"priority": 2,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"acceptance": "Can search Perplexity and parse response with sources"
|
||||
},
|
||||
{
|
||||
@@ -44,7 +44,7 @@
|
||||
"name": "Architecture Classification",
|
||||
"description": "Claude prompt for classifying app type",
|
||||
"priority": 3,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"acceptance": "Given idea text, returns valid Architecture JSON"
|
||||
},
|
||||
{
|
||||
@@ -53,7 +53,7 @@
|
||||
"name": "Research Query Generation",
|
||||
"description": "Generate Perplexity queries from architecture",
|
||||
"priority": 4,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"acceptance": "Given architecture, generates 4-6 relevant search queries"
|
||||
},
|
||||
{
|
||||
|
||||
21
progress.txt
21
progress.txt
@@ -18,3 +18,24 @@
|
||||
* ralph-vibe init/new/validate/research exist
|
||||
* 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
|
||||
|
||||
|
||||
172
src/__tests__/architecture.test.ts
Normal file
172
src/__tests__/architecture.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
199
src/__tests__/claude.test.ts
Normal file
199
src/__tests__/claude.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
235
src/__tests__/perplexity.test.ts
Normal file
235
src/__tests__/perplexity.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
235
src/__tests__/templates.test.ts
Normal file
235
src/__tests__/templates.test.ts
Normal 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
274
src/clients/claude.ts
Normal 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
232
src/clients/perplexity.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
93
src/generators/architecture.ts
Normal file
93
src/generators/architecture.ts
Normal 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
151
src/prompts/templates.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user