rdev/internal/service/architect_service.go
jordan c68fadbccd
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix(architect): add pod_name to agent requests, rewrite foundary cookbook
The architect service was missing pod_name/namespace in AgentRequest
metadata, causing Claude Code adapter to reject all requests. Added
ArchitectServiceConfig with pod resolution (project PodName → default
claudebox-0). Removed silent JSON fallback in extractSpecFromMessages
that masked errors.

Rewrote foundary cookbook from 90-step SDLC flow to focused 25-step
cookbook using natural language build prompts instead of /slash-commands
that claudebox cannot execute. Added "no fallbacks" rule to CLAUDE.md.

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

324 lines
9.9 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")
}
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
// Resolve pod: use project's pod if set, otherwise fall back to default.
podName := project.PodName
if podName == "" {
podName = s.defaultPodName
}
agentReq := &domain.AgentRequest{
Prompt: fullPrompt,
ProjectID: project.ID,
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")
}
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)
// Resolve pod: use project's pod if set, otherwise fall back to default.
podName := project.PodName
if podName == "" {
podName = s.defaultPodName
}
agentReq := &domain.AgentRequest{
Prompt: extractionPrompt,
ProjectID: project.ID,
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
}