rdev/internal/service/architect_service.go
jordan a69eb7e587 feat(foundary): implement complete backend for conversational project design
Implements all 5 phases of Foundary Studio backend:

Phase 1: Chat Persistence (8 API endpoints)
- Conversations and messages with proper cascading deletes
- PostgreSQL schema with auto-update triggers
- Full CRUD operations with structured logging

Phase 2: Blueprint Entity (5 API endpoints)
- JSONB spec storage with GIN indexes
- Flexible structured data for project specifications
- Version-controlled blueprint management

Phase 3: Architect Service (3 API endpoints)
- Conversational AI orchestration with Claude
- Multi-turn dialogue with context building
- Blueprint spec extraction from conversations

Phase 4: Work Queue Integration
- Verified existing endpoint compatibility

Phase 5: Structured Questions (6 API endpoints)
- Four question types: text, choice, multichoice, yesno
- Answer validation with proper constraints
- Conversation-linked Q&A flow

Architecture:
- Textbook hexagonal architecture (domain → port → adapter → service → handler)
- Zero external dependencies in domain layer
- Consistent error handling with proper wrapping
- Auth scopes on all routes (projects:read, projects:execute)
- Structured logging with operation context and duration tracking
- NULL-safe DTO converters throughout

Database:
- 3 new migrations (019, 020, 021)
- UUIDs for all primary keys
- Proper foreign key constraints with ON DELETE CASCADE
- Optimized indexes including partial index for unanswered questions
- Auto-update triggers for timestamps

OpenAPI Documentation:
- Complete API documentation under 'Foundary' tag
- 22 new endpoints documented with examples
- Request/response schemas for all operations

Logging Improvements:
- Added operation field to all service logs
- Added duration_ms tracking for performance monitoring
- Log response_length instead of full response content
- Consistent use of logging field constants
- Execute-then-log pattern for delete operations

Files: 32 changed, 2800+ lines added
- 7 domain models
- 3 database migrations
- 3 port interfaces
- 3 postgres adapters
- 4 services (conversation, blueprint, question, architect)
- 4 handlers with DTOs
- OpenAPI documentation
- Integration in main.go

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-09 00:50:46 -07:00

304 lines
9.4 KiB
Go

package service
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/logging"
"github.com/orchard9/rdev/internal/port"
)
// ArchitectService orchestrates conversational project design with Claude.
type ArchitectService struct {
conversationService *ConversationService
blueprintService *BlueprintService
agentRegistry port.CodeAgentRegistry
projectRepo port.ProjectRepository
}
// NewArchitectService creates a new architect service.
func NewArchitectService(
conversationService *ConversationService,
blueprintService *BlueprintService,
agentRegistry port.CodeAgentRegistry,
projectRepo port.ProjectRepository,
) *ArchitectService {
return &ArchitectService{
conversationService: conversationService,
blueprintService: blueprintService,
agentRegistry: agentRegistry,
projectRepo: projectRepo,
}
}
// StartConversation begins a new architectural conversation.
func (s *ArchitectService) StartConversation(ctx context.Context, projectID, initialPrompt string) (*domain.ConversationWithMessages, error) {
// Create conversation
conv, err := s.conversationService.CreateConversation(ctx, projectID, "Architectural Design")
if err != nil {
return nil, fmt.Errorf("create conversation: %w", err)
}
// Add user's initial message
_, err = s.conversationService.AddMessage(ctx, conv.ID, domain.MessageRoleUser, initialPrompt)
if err != nil {
return nil, fmt.Errorf("add user message: %w", err)
}
// Get agent response
response, err := s.askArchitect(ctx, projectID, conv.ID, initialPrompt)
if err != nil {
return nil, fmt.Errorf("ask architect: %w", err)
}
// Add assistant's response
_, err = s.conversationService.AddMessage(ctx, conv.ID, domain.MessageRoleAssistant, response)
if err != nil {
return nil, fmt.Errorf("add assistant message: %w", err)
}
log := logging.FromContext(ctx)
log.Info("architectural conversation started",
"conversation_id", conv.ID,
logging.FieldProjectID, projectID,
logging.FieldOperation, "start_architect_conversation",
)
return s.conversationService.GetConversationWithMessages(ctx, conv.ID)
}
// ContinueConversation adds a message and gets agent response.
func (s *ArchitectService) ContinueConversation(ctx context.Context, conversationID domain.ConversationID, userMessage string) (*domain.Message, error) {
// Get conversation to find project
conv, err := s.conversationService.GetConversation(ctx, conversationID)
if err != nil {
return nil, err
}
// Add user message
_, err = s.conversationService.AddMessage(ctx, conversationID, domain.MessageRoleUser, userMessage)
if err != nil {
return nil, fmt.Errorf("add user message: %w", err)
}
// Get conversation history for context
messages, err := s.conversationService.GetMessages(ctx, conversationID)
if err != nil {
return nil, err
}
// Build context from history
context := s.buildConversationContext(messages)
// Get agent response
response, err := s.askArchitect(ctx, conv.ProjectID, conversationID, context)
if err != nil {
return nil, fmt.Errorf("ask architect: %w", err)
}
// Add assistant's response
return s.conversationService.AddMessage(ctx, conversationID, domain.MessageRoleAssistant, response)
}
// GenerateBlueprint creates a blueprint from a conversation.
func (s *ArchitectService) GenerateBlueprint(ctx context.Context, conversationID domain.ConversationID, blueprintName string) (*domain.Blueprint, error) {
conv, err := s.conversationService.GetConversation(ctx, conversationID)
if err != nil {
return nil, err
}
messages, err := s.conversationService.GetMessages(ctx, conversationID)
if err != nil {
return nil, err
}
// Extract structured spec from conversation
spec, err := s.extractSpecFromMessages(ctx, conv.ProjectID, messages)
if err != nil {
return nil, fmt.Errorf("extract spec: %w", err)
}
// Create blueprint
return s.blueprintService.CreateBlueprint(ctx, conv.ProjectID, blueprintName, "Generated from architectural conversation", spec)
}
// askArchitect sends a prompt to Claude and gets the response.
func (s *ArchitectService) askArchitect(ctx context.Context, projectID string, conversationID domain.ConversationID, prompt string) (string, error) {
agent := s.agentRegistry.Default()
if agent == nil {
return "", fmt.Errorf("no agent available")
}
project, err := s.projectRepo.Get(ctx, domain.ProjectID(projectID))
if err != nil {
return "", fmt.Errorf("resolve project: %w", err)
}
// Prepare architect-specific system prompt
systemPrompt := `You are an expert software architect helping design a new project.
Your role is to:
1. Ask clarifying questions to understand requirements deeply
2. Identify technical architecture and stack choices
3. Recommend component structure (monorepo, microservices, etc.)
4. Define infrastructure needs (database, cache, storage, messaging)
5. Consider scalability, maintainability, and team capabilities
Guidelines:
- Ask one focused question at a time
- Provide specific recommendations with trade-offs
- Think about the full system: data models, APIs, UI components, infrastructure
- When you have enough information, summarize the architecture clearly
Current conversation context:`
fullPrompt := systemPrompt + "\n\n" + prompt
agentReq := &domain.AgentRequest{
Prompt: fullPrompt,
ProjectID: project.ID,
Timeout: 2 * time.Minute,
Metadata: map[string]string{
"conversation_id": string(conversationID),
"purpose": "architect",
},
}
var output strings.Builder
_, err = agent.Execute(ctx, agentReq, func(event domain.AgentEvent) {
if event.Type == domain.AgentEventOutput {
output.WriteString(event.Content)
}
})
if err != nil {
return "", fmt.Errorf("agent execution: %w", err)
}
return output.String(), nil
}
// buildConversationContext combines message history into a single context string.
func (s *ArchitectService) buildConversationContext(messages []*domain.Message) string {
var context strings.Builder
for _, msg := range messages {
role := string(msg.Role)
context.WriteString(fmt.Sprintf("%s: %s\n\n", role, msg.Content))
}
return context.String()
}
// extractSpecFromMessages parses conversation to extract structured blueprint spec.
func (s *ArchitectService) extractSpecFromMessages(ctx context.Context, projectID string, messages []*domain.Message) (map[string]any, error) {
// Use Claude to analyze conversation and extract structured data
agent := s.agentRegistry.Default()
if agent == nil {
return nil, fmt.Errorf("no agent available")
}
project, err := s.projectRepo.Get(ctx, domain.ProjectID(projectID))
if err != nil {
return nil, fmt.Errorf("resolve project: %w", err)
}
// Build conversation transcript
transcript := s.buildConversationContext(messages)
// Prompt to extract structured spec
extractionPrompt := fmt.Sprintf(`Analyze this architectural conversation and extract a structured JSON specification.
Conversation transcript:
%s
Extract and return ONLY a valid JSON object with this structure:
{
"version": "1.0",
"architecture": {
"type": "monorepo|microservices|monolith",
"description": "...",
"components": [
{"name": "...", "type": "service|app|worker|cli", "description": "..."}
]
},
"data_models": [
{"name": "...", "description": "...", "fields": [...]}
],
"api_endpoints": [
{"path": "...", "method": "GET|POST|PUT|DELETE", "description": "..."}
],
"infrastructure": {
"database": {"type": "postgres|mysql|mongo", "required": true|false},
"cache": {"type": "redis|memcached", "required": true|false},
"storage": {"type": "s3|gcs", "required": true|false},
"messaging": {"type": "rabbitmq|kafka", "required": true|false}
},
"features": [
{"name": "...", "description": "...", "priority": "high|medium|low"}
]
}
Return ONLY the JSON, no other text.`, transcript)
agentReq := &domain.AgentRequest{
Prompt: extractionPrompt,
ProjectID: project.ID,
Timeout: 2 * time.Minute,
Metadata: map[string]string{
"purpose": "spec-extraction",
},
}
var output strings.Builder
_, err = agent.Execute(ctx, agentReq, func(event domain.AgentEvent) {
if event.Type == domain.AgentEventOutput {
output.WriteString(event.Content)
}
})
if err != nil {
return nil, fmt.Errorf("agent execution: %w", err)
}
// Parse JSON response
response := output.String()
// Extract JSON from markdown code blocks if present
if strings.Contains(response, "```json") {
start := strings.Index(response, "```json") + 7
end := strings.Index(response[start:], "```")
if end != -1 {
response = response[start : start+end]
}
} else if strings.Contains(response, "```") {
start := strings.Index(response, "```") + 3
end := strings.Index(response[start:], "```")
if end != -1 {
response = response[start : start+end]
}
}
response = strings.TrimSpace(response)
var spec map[string]any
if err := json.Unmarshal([]byte(response), &spec); err != nil {
// Fallback: create basic spec if parsing fails
log := logging.FromContext(ctx)
log.Warn("failed to parse agent spec extraction, using fallback",
logging.FieldError, err,
logging.FieldOperation, "extract_blueprint_spec",
"response_length", len(response),
"message_count", len(messages),
)
spec = map[string]any{
"version": "1.0",
"generated_at": time.Now().Format(time.RFC3339),
"message_count": len(messages),
"extraction_failed": true,
}
}
return spec, nil
}