rdev/internal/service/architect_service.go
jordan 542bc722ab
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix(architect): handle missing projects in repo, add cookbook hooks/validation
The architect API returned "failed to start conversation" because
projectRepo.Get() failed — the in-memory K8s repo watches the rdev
namespace but projects deploy to the projects namespace. Made project
lookup non-fatal with fallback to default pod. Added error logging to
all architect handler methods (were silently swallowing errors).

Also adds setup-hooks, commit-after-qa, and pre-merge-validate steps
to the foundary cookbook tree for git hooks and code quality gates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 02:25:40 -07:00

331 lines
10 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"
)
// ArchitectServiceConfig holds configuration for the architect service.
type ArchitectServiceConfig struct {
DefaultPodName string // Default pod for agent execution (e.g., "claudebox-0")
Namespace string // Kubernetes namespace (e.g., "rdev")
}
// ArchitectService orchestrates conversational project design with Claude.
type ArchitectService struct {
conversationService *ConversationService
blueprintService *BlueprintService
agentRegistry port.CodeAgentRegistry
projectRepo port.ProjectRepository
defaultPodName string
namespace string
}
// NewArchitectService creates a new architect service.
func NewArchitectService(
conversationService *ConversationService,
blueprintService *BlueprintService,
agentRegistry port.CodeAgentRegistry,
projectRepo port.ProjectRepository,
cfg *ArchitectServiceConfig,
) *ArchitectService {
if cfg == nil {
cfg = &ArchitectServiceConfig{
DefaultPodName: "claudebox-0",
Namespace: "rdev",
}
}
return &ArchitectService{
conversationService: conversationService,
blueprintService: blueprintService,
agentRegistry: agentRegistry,
projectRepo: projectRepo,
defaultPodName: cfg.DefaultPodName,
namespace: cfg.Namespace,
}
}
// 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")
}
// Resolve project pod name. The in-memory project repo may not have the
// project (it discovers pods in the rdev namespace, but projects deploy to
// the projects namespace). Fall back to defaults when not found.
podName := s.defaultPodName
project, err := s.projectRepo.Get(ctx, domain.ProjectID(projectID))
if err != nil {
log := logging.FromContext(ctx)
log.Warn("project not found in repo, using default pod",
logging.FieldProjectID, projectID,
"default_pod", podName,
logging.FieldError, err,
)
} else if project.PodName != "" {
podName = project.PodName
}
// 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: domain.ProjectID(projectID),
Timeout: 2 * time.Minute,
Metadata: map[string]string{
"conversation_id": string(conversationID),
"purpose": "architect",
"pod_name": podName,
"namespace": s.namespace,
},
}
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")
}
// Resolve project pod name with fallback (same as askArchitect).
podName := s.defaultPodName
project, err := s.projectRepo.Get(ctx, domain.ProjectID(projectID))
if err != nil {
log := logging.FromContext(ctx)
log.Warn("project not found in repo for spec extraction, using default pod",
logging.FieldProjectID, projectID,
logging.FieldError, err,
)
} else if project.PodName != "" {
podName = project.PodName
}
// 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: domain.ProjectID(projectID),
Timeout: 2 * time.Minute,
Metadata: map[string]string{
"purpose": "spec-extraction",
"pod_name": podName,
"namespace": s.namespace,
},
}
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 {
return nil, fmt.Errorf("parse spec extraction response: %w", err)
}
return spec, nil
}