| iomodo | d9ff8da | 2025-07-28 11:42:22 +0400 | [diff] [blame^] | 1 | package subtasks |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "log" |
| 8 | "strings" |
| 9 | |
| 10 | "github.com/iomodo/staff/llm" |
| 11 | "github.com/iomodo/staff/tm" |
| 12 | ) |
| 13 | |
| 14 | // SubtaskService handles subtask generation and management |
| 15 | type SubtaskService struct { |
| 16 | llmProvider llm.LLMProvider |
| 17 | taskManager tm.TaskManager |
| 18 | agentRoles []string // Available agent roles for assignment |
| 19 | } |
| 20 | |
| 21 | // NewSubtaskService creates a new subtask service |
| 22 | func NewSubtaskService(provider llm.LLMProvider, taskManager tm.TaskManager, agentRoles []string) *SubtaskService { |
| 23 | return &SubtaskService{ |
| 24 | llmProvider: provider, |
| 25 | taskManager: taskManager, |
| 26 | agentRoles: agentRoles, |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | // AnalyzeTaskForSubtasks uses LLM to analyze a task and propose subtasks |
| 31 | func (s *SubtaskService) AnalyzeTaskForSubtasks(ctx context.Context, task *tm.Task) (*tm.SubtaskAnalysis, error) { |
| 32 | prompt := s.buildSubtaskAnalysisPrompt(task) |
| 33 | |
| 34 | req := llm.ChatCompletionRequest{ |
| 35 | Model: "gpt-4", |
| 36 | Messages: []llm.Message{ |
| 37 | { |
| 38 | Role: llm.RoleSystem, |
| 39 | Content: s.getSubtaskAnalysisSystemPrompt(), |
| 40 | }, |
| 41 | { |
| 42 | Role: llm.RoleUser, |
| 43 | Content: prompt, |
| 44 | }, |
| 45 | }, |
| 46 | MaxTokens: &[]int{4000}[0], |
| 47 | Temperature: &[]float64{0.3}[0], |
| 48 | } |
| 49 | |
| 50 | resp, err := s.llmProvider.ChatCompletion(ctx, req) |
| 51 | if err != nil { |
| 52 | return nil, fmt.Errorf("LLM analysis failed: %w", err) |
| 53 | } |
| 54 | |
| 55 | if len(resp.Choices) == 0 { |
| 56 | return nil, fmt.Errorf("no response from LLM") |
| 57 | } |
| 58 | |
| 59 | // Parse the LLM response |
| 60 | analysis, err := s.parseSubtaskAnalysis(resp.Choices[0].Message.Content, task.ID) |
| 61 | if err != nil { |
| 62 | return nil, fmt.Errorf("failed to parse LLM response: %w", err) |
| 63 | } |
| 64 | |
| 65 | return analysis, nil |
| 66 | } |
| 67 | |
| 68 | // getSubtaskAnalysisSystemPrompt returns the system prompt for subtask analysis |
| 69 | func (s *SubtaskService) getSubtaskAnalysisSystemPrompt() string { |
| 70 | availableRoles := strings.Join(s.agentRoles, ", ") |
| 71 | |
| 72 | return fmt.Sprintf(`You are an expert project manager and technical architect. Your job is to analyze complex tasks and break them down into well-defined subtasks that can be assigned to specialized team members. |
| 73 | |
| 74 | Available team roles: %s |
| 75 | |
| 76 | When analyzing a task, you should: |
| 77 | 1. Understand the task requirements and scope |
| 78 | 2. Break it down into logical, manageable subtasks |
| 79 | 3. Assign each subtask to the most appropriate team role |
| 80 | 4. Estimate effort and identify dependencies |
| 81 | 5. Provide a clear execution strategy |
| 82 | |
| 83 | Respond with a JSON object in this exact format: |
| 84 | { |
| 85 | "analysis_summary": "Brief analysis of the task and approach", |
| 86 | "subtasks": [ |
| 87 | { |
| 88 | "title": "Subtask title", |
| 89 | "description": "Detailed description of what needs to be done", |
| 90 | "priority": "high|medium|low", |
| 91 | "assigned_to": "role_name", |
| 92 | "estimated_hours": 8, |
| 93 | "dependencies": ["subtask_index_1", "subtask_index_2"] |
| 94 | } |
| 95 | ], |
| 96 | "recommended_approach": "High-level strategy for executing these subtasks", |
| 97 | "estimated_total_hours": 40, |
| 98 | "risk_assessment": "Potential risks and mitigation strategies" |
| 99 | } |
| 100 | |
| 101 | Only use the available team roles for assignment. Dependencies should reference subtask indices (e.g., ["0", "1"] means depends on first and second subtasks).`, availableRoles) |
| 102 | } |
| 103 | |
| 104 | // buildSubtaskAnalysisPrompt creates the user prompt for LLM analysis |
| 105 | func (s *SubtaskService) buildSubtaskAnalysisPrompt(task *tm.Task) string { |
| 106 | return fmt.Sprintf(`Please analyze the following task and break it down into subtasks: |
| 107 | |
| 108 | **Task Title:** %s |
| 109 | |
| 110 | **Description:** %s |
| 111 | |
| 112 | **Priority:** %s |
| 113 | |
| 114 | **Current Status:** %s |
| 115 | |
| 116 | Please analyze this task and provide a detailed breakdown into subtasks. Consider: |
| 117 | - Technical complexity and requirements |
| 118 | - Logical task dependencies |
| 119 | - Appropriate skill sets needed for each subtask |
| 120 | - Risk factors and potential blockers |
| 121 | - Estimated effort for each component |
| 122 | |
| 123 | Provide the analysis in the JSON format specified in the system prompt.`, |
| 124 | task.Title, |
| 125 | task.Description, |
| 126 | task.Priority, |
| 127 | task.Status) |
| 128 | } |
| 129 | |
| 130 | // parseSubtaskAnalysis parses the LLM response into a SubtaskAnalysis struct |
| 131 | func (s *SubtaskService) parseSubtaskAnalysis(response string, parentTaskID string) (*tm.SubtaskAnalysis, error) { |
| 132 | // Try to extract JSON from the response (LLM might wrap it in markdown) |
| 133 | jsonStart := strings.Index(response, "{") |
| 134 | jsonEnd := strings.LastIndex(response, "}") |
| 135 | |
| 136 | if jsonStart == -1 || jsonEnd == -1 { |
| 137 | return nil, fmt.Errorf("no JSON found in LLM response") |
| 138 | } |
| 139 | |
| 140 | jsonStr := response[jsonStart : jsonEnd+1] |
| 141 | |
| 142 | var rawAnalysis struct { |
| 143 | AnalysisSummary string `json:"analysis_summary"` |
| 144 | Subtasks []struct { |
| 145 | Title string `json:"title"` |
| 146 | Description string `json:"description"` |
| 147 | Priority string `json:"priority"` |
| 148 | AssignedTo string `json:"assigned_to"` |
| 149 | EstimatedHours int `json:"estimated_hours"` |
| 150 | Dependencies []string `json:"dependencies"` |
| 151 | } `json:"subtasks"` |
| 152 | RecommendedApproach string `json:"recommended_approach"` |
| 153 | EstimatedTotalHours int `json:"estimated_total_hours"` |
| 154 | RiskAssessment string `json:"risk_assessment"` |
| 155 | } |
| 156 | |
| 157 | if err := json.Unmarshal([]byte(jsonStr), &rawAnalysis); err != nil { |
| 158 | return nil, fmt.Errorf("failed to unmarshal JSON: %w", err) |
| 159 | } |
| 160 | |
| 161 | // Convert to our types |
| 162 | analysis := &tm.SubtaskAnalysis{ |
| 163 | ParentTaskID: parentTaskID, |
| 164 | AnalysisSummary: rawAnalysis.AnalysisSummary, |
| 165 | RecommendedApproach: rawAnalysis.RecommendedApproach, |
| 166 | EstimatedTotalHours: rawAnalysis.EstimatedTotalHours, |
| 167 | RiskAssessment: rawAnalysis.RiskAssessment, |
| 168 | } |
| 169 | |
| 170 | // Convert subtasks |
| 171 | for _, st := range rawAnalysis.Subtasks { |
| 172 | priority := tm.PriorityMedium // default |
| 173 | switch strings.ToLower(st.Priority) { |
| 174 | case "high": |
| 175 | priority = tm.PriorityHigh |
| 176 | case "low": |
| 177 | priority = tm.PriorityLow |
| 178 | } |
| 179 | |
| 180 | subtask := tm.SubtaskProposal{ |
| 181 | Title: st.Title, |
| 182 | Description: st.Description, |
| 183 | Priority: priority, |
| 184 | AssignedTo: st.AssignedTo, |
| 185 | EstimatedHours: st.EstimatedHours, |
| 186 | Dependencies: st.Dependencies, |
| 187 | } |
| 188 | |
| 189 | analysis.Subtasks = append(analysis.Subtasks, subtask) |
| 190 | } |
| 191 | |
| 192 | // Validate agent assignments |
| 193 | if err := s.validateAgentAssignments(analysis); err != nil { |
| 194 | log.Printf("Warning: Invalid agent assignments: %v", err) |
| 195 | // Fix assignments by using first available role |
| 196 | s.fixAgentAssignments(analysis) |
| 197 | } |
| 198 | |
| 199 | return analysis, nil |
| 200 | } |
| 201 | |
| 202 | // validateAgentAssignments checks if all assigned roles are valid |
| 203 | func (s *SubtaskService) validateAgentAssignments(analysis *tm.SubtaskAnalysis) error { |
| 204 | for i, subtask := range analysis.Subtasks { |
| 205 | if !s.isValidAgentRole(subtask.AssignedTo) { |
| 206 | return fmt.Errorf("subtask %d has invalid agent role: %s", i, subtask.AssignedTo) |
| 207 | } |
| 208 | } |
| 209 | return nil |
| 210 | } |
| 211 | |
| 212 | // fixAgentAssignments fixes invalid agent assignments |
| 213 | func (s *SubtaskService) fixAgentAssignments(analysis *tm.SubtaskAnalysis) { |
| 214 | defaultRole := "ceo" // fallback role |
| 215 | if len(s.agentRoles) > 0 { |
| 216 | defaultRole = s.agentRoles[0] |
| 217 | } |
| 218 | |
| 219 | for i := range analysis.Subtasks { |
| 220 | if !s.isValidAgentRole(analysis.Subtasks[i].AssignedTo) { |
| 221 | analysis.Subtasks[i].AssignedTo = defaultRole |
| 222 | } |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | // isValidAgentRole checks if a role is in the available agent roles |
| 227 | func (s *SubtaskService) isValidAgentRole(role string) bool { |
| 228 | for _, availableRole := range s.agentRoles { |
| 229 | if availableRole == role { |
| 230 | return true |
| 231 | } |
| 232 | } |
| 233 | return false |
| 234 | } |
| 235 | |
| 236 | // GenerateSubtaskPR creates a PR with the proposed subtasks |
| 237 | func (s *SubtaskService) GenerateSubtaskPR(ctx context.Context, analysis *tm.SubtaskAnalysis) (string, error) { |
| 238 | // Generate markdown content for the PR |
| 239 | prContent := s.generateSubtaskPRContent(analysis) |
| 240 | |
| 241 | // This would typically create a Git branch and PR |
| 242 | // For now, we'll return a mock PR URL |
| 243 | prURL := fmt.Sprintf("https://github.com/example/repo/pull/subtasks-%s", analysis.ParentTaskID) |
| 244 | |
| 245 | log.Printf("Generated subtask proposal PR: %s", prURL) |
| 246 | log.Printf("PR Content:\n%s", prContent) |
| 247 | |
| 248 | return prURL, nil |
| 249 | } |
| 250 | |
| 251 | // generateSubtaskPRContent creates markdown content for the subtask proposal PR |
| 252 | func (s *SubtaskService) generateSubtaskPRContent(analysis *tm.SubtaskAnalysis) string { |
| 253 | var content strings.Builder |
| 254 | |
| 255 | content.WriteString(fmt.Sprintf("# Subtask Proposal for Task %s\n\n", analysis.ParentTaskID)) |
| 256 | content.WriteString(fmt.Sprintf("## Analysis Summary\n%s\n\n", analysis.AnalysisSummary)) |
| 257 | content.WriteString(fmt.Sprintf("## Recommended Approach\n%s\n\n", analysis.RecommendedApproach)) |
| 258 | content.WriteString(fmt.Sprintf("**Estimated Total Hours:** %d\n\n", analysis.EstimatedTotalHours)) |
| 259 | |
| 260 | if analysis.RiskAssessment != "" { |
| 261 | content.WriteString(fmt.Sprintf("## Risk Assessment\n%s\n\n", analysis.RiskAssessment)) |
| 262 | } |
| 263 | |
| 264 | content.WriteString("## Proposed Subtasks\n\n") |
| 265 | |
| 266 | for i, subtask := range analysis.Subtasks { |
| 267 | content.WriteString(fmt.Sprintf("### %d. %s\n", i+1, subtask.Title)) |
| 268 | content.WriteString(fmt.Sprintf("- **Assigned to:** %s\n", subtask.AssignedTo)) |
| 269 | content.WriteString(fmt.Sprintf("- **Priority:** %s\n", subtask.Priority)) |
| 270 | content.WriteString(fmt.Sprintf("- **Estimated Hours:** %d\n", subtask.EstimatedHours)) |
| 271 | |
| 272 | if len(subtask.Dependencies) > 0 { |
| 273 | deps := strings.Join(subtask.Dependencies, ", ") |
| 274 | content.WriteString(fmt.Sprintf("- **Dependencies:** %s\n", deps)) |
| 275 | } |
| 276 | |
| 277 | content.WriteString(fmt.Sprintf("- **Description:** %s\n\n", subtask.Description)) |
| 278 | } |
| 279 | |
| 280 | content.WriteString("---\n") |
| 281 | content.WriteString("*Generated by Staff AI Agent System*\n\n") |
| 282 | content.WriteString("**Instructions:**\n") |
| 283 | content.WriteString("- Review the proposed subtasks\n") |
| 284 | content.WriteString("- Approve or request changes\n") |
| 285 | content.WriteString("- When merged, the subtasks will be automatically created and assigned\n") |
| 286 | |
| 287 | return content.String() |
| 288 | } |
| 289 | |
| 290 | // Close cleans up the service |
| 291 | func (s *SubtaskService) Close() error { |
| 292 | if s.llmProvider != nil { |
| 293 | return s.llmProvider.Close() |
| 294 | } |
| 295 | return nil |
| 296 | } |