All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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>
324 lines
9.9 KiB
Go
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
|
|
}
|