aidp 0.17.1 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +69 -0
- data/lib/aidp/cli.rb +43 -2
- data/lib/aidp/config.rb +9 -14
- data/lib/aidp/execute/prompt_manager.rb +128 -1
- data/lib/aidp/execute/repl_macros.rb +555 -0
- data/lib/aidp/execute/work_loop_runner.rb +108 -1
- data/lib/aidp/harness/ai_decision_engine.rb +376 -0
- data/lib/aidp/harness/capability_registry.rb +273 -0
- data/lib/aidp/harness/config_schema.rb +305 -1
- data/lib/aidp/harness/configuration.rb +452 -0
- data/lib/aidp/harness/enhanced_runner.rb +7 -1
- data/lib/aidp/harness/provider_factory.rb +0 -2
- data/lib/aidp/harness/runner.rb +7 -1
- data/lib/aidp/harness/thinking_depth_manager.rb +335 -0
- data/lib/aidp/harness/zfc_condition_detector.rb +395 -0
- data/lib/aidp/init/devcontainer_generator.rb +274 -0
- data/lib/aidp/init/runner.rb +37 -10
- data/lib/aidp/init.rb +1 -0
- data/lib/aidp/prompt_optimization/context_composer.rb +286 -0
- data/lib/aidp/prompt_optimization/optimizer.rb +335 -0
- data/lib/aidp/prompt_optimization/prompt_builder.rb +309 -0
- data/lib/aidp/prompt_optimization/relevance_scorer.rb +256 -0
- data/lib/aidp/prompt_optimization/source_code_fragmenter.rb +308 -0
- data/lib/aidp/prompt_optimization/style_guide_indexer.rb +240 -0
- data/lib/aidp/prompt_optimization/template_indexer.rb +250 -0
- data/lib/aidp/provider_manager.rb +0 -2
- data/lib/aidp/providers/anthropic.rb +19 -0
- data/lib/aidp/setup/wizard.rb +299 -4
- data/lib/aidp/utils/devcontainer_detector.rb +166 -0
- data/lib/aidp/version.rb +1 -1
- data/lib/aidp/watch/build_processor.rb +72 -6
- data/lib/aidp/watch/repository_client.rb +2 -1
- data/lib/aidp.rb +0 -1
- data/templates/aidp.yml.example +128 -0
- metadata +14 -2
- data/lib/aidp/providers/macos_ui.rb +0 -102
| @@ -0,0 +1,335 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            require_relative "style_guide_indexer"
         | 
| 4 | 
            +
            require_relative "template_indexer"
         | 
| 5 | 
            +
            require_relative "source_code_fragmenter"
         | 
| 6 | 
            +
            require_relative "relevance_scorer"
         | 
| 7 | 
            +
            require_relative "context_composer"
         | 
| 8 | 
            +
            require_relative "prompt_builder"
         | 
| 9 | 
            +
             | 
| 10 | 
            +
            module Aidp
         | 
| 11 | 
            +
              module PromptOptimization
         | 
| 12 | 
            +
                # Main coordinator for prompt optimization
         | 
| 13 | 
            +
                #
         | 
| 14 | 
            +
                # Orchestrates all components to produce an optimized prompt:
         | 
| 15 | 
            +
                # 1. Index style guide, templates, and source code
         | 
| 16 | 
            +
                # 2. Score fragments based on task context
         | 
| 17 | 
            +
                # 3. Select optimal fragments within token budget
         | 
| 18 | 
            +
                # 4. Build final prompt markdown
         | 
| 19 | 
            +
                #
         | 
| 20 | 
            +
                # @example Basic usage
         | 
| 21 | 
            +
                #   optimizer = Optimizer.new(project_dir: "/project", config: config)
         | 
| 22 | 
            +
                #   result = optimizer.optimize_prompt(
         | 
| 23 | 
            +
                #     task_type: :feature,
         | 
| 24 | 
            +
                #     description: "Add user auth",
         | 
| 25 | 
            +
                #     affected_files: ["lib/user.rb"],
         | 
| 26 | 
            +
                #     step_name: "implementation"
         | 
| 27 | 
            +
                #   )
         | 
| 28 | 
            +
                #   result.write_to_file("PROMPT.md")
         | 
| 29 | 
            +
                class Optimizer
         | 
| 30 | 
            +
                  attr_reader :project_dir, :config, :stats
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                  def initialize(project_dir:, config: nil)
         | 
| 33 | 
            +
                    @project_dir = project_dir
         | 
| 34 | 
            +
                    @config = config || default_config
         | 
| 35 | 
            +
                    @stats = OptimizerStats.new
         | 
| 36 | 
            +
             | 
| 37 | 
            +
                    # Initialize indexers (will cache results)
         | 
| 38 | 
            +
                    @style_guide_indexer = nil
         | 
| 39 | 
            +
                    @template_indexer = nil
         | 
| 40 | 
            +
                    @fragmenter = nil
         | 
| 41 | 
            +
                    @scorer = nil
         | 
| 42 | 
            +
                    @composer = nil
         | 
| 43 | 
            +
                    @builder = nil
         | 
| 44 | 
            +
                  end
         | 
| 45 | 
            +
             | 
| 46 | 
            +
                  # Optimize prompt for given task context
         | 
| 47 | 
            +
                  #
         | 
| 48 | 
            +
                  # @param task_type [Symbol] Type of task (:feature, :bugfix, etc.)
         | 
| 49 | 
            +
                  # @param description [String] Task description
         | 
| 50 | 
            +
                  # @param affected_files [Array<String>] Files being modified
         | 
| 51 | 
            +
                  # @param step_name [String] Current work loop step
         | 
| 52 | 
            +
                  # @param tags [Array<String>] Additional context tags
         | 
| 53 | 
            +
                  # @param options [Hash] Additional options
         | 
| 54 | 
            +
                  # @option options [Boolean] :include_metadata Include optimization metadata
         | 
| 55 | 
            +
                  # @option options [Integer] :max_tokens Override default token budget
         | 
| 56 | 
            +
                  # @return [PromptOutput] Optimized prompt with metadata
         | 
| 57 | 
            +
                  def optimize_prompt(task_type: nil, description: nil, affected_files: [], step_name: nil, tags: [], options: {})
         | 
| 58 | 
            +
                    start_time = Time.now
         | 
| 59 | 
            +
             | 
| 60 | 
            +
                    # Build task context
         | 
| 61 | 
            +
                    task_context = TaskContext.new(
         | 
| 62 | 
            +
                      task_type: task_type,
         | 
| 63 | 
            +
                      description: description,
         | 
| 64 | 
            +
                      affected_files: affected_files,
         | 
| 65 | 
            +
                      step_name: step_name,
         | 
| 66 | 
            +
                      tags: tags
         | 
| 67 | 
            +
                    )
         | 
| 68 | 
            +
             | 
| 69 | 
            +
                    # Index all fragments
         | 
| 70 | 
            +
                    all_fragments = index_all_fragments(affected_files)
         | 
| 71 | 
            +
                    @stats.record_fragments_indexed(all_fragments.count)
         | 
| 72 | 
            +
             | 
| 73 | 
            +
                    # Score fragments
         | 
| 74 | 
            +
                    scored_fragments = score_fragments(all_fragments, task_context)
         | 
| 75 | 
            +
                    @stats.record_fragments_scored(scored_fragments.count)
         | 
| 76 | 
            +
             | 
| 77 | 
            +
                    # Select fragments within budget
         | 
| 78 | 
            +
                    max_tokens = options[:max_tokens] || @config[:max_tokens]
         | 
| 79 | 
            +
                    thresholds = @config[:include_threshold]
         | 
| 80 | 
            +
                    composition_result = compose_context(scored_fragments, max_tokens, thresholds)
         | 
| 81 | 
            +
             | 
| 82 | 
            +
                    @stats.record_fragments_selected(composition_result.selected_count)
         | 
| 83 | 
            +
                    @stats.record_fragments_excluded(composition_result.excluded_count)
         | 
| 84 | 
            +
                    @stats.record_tokens_used(composition_result.total_tokens)
         | 
| 85 | 
            +
                    @stats.record_budget_utilization(composition_result.budget_utilization)
         | 
| 86 | 
            +
             | 
| 87 | 
            +
                    # Build final prompt
         | 
| 88 | 
            +
                    prompt_output = build_prompt(task_context, composition_result, options)
         | 
| 89 | 
            +
             | 
| 90 | 
            +
                    elapsed = Time.now - start_time
         | 
| 91 | 
            +
                    @stats.record_optimization_time(elapsed)
         | 
| 92 | 
            +
             | 
| 93 | 
            +
                    log_optimization_result(prompt_output) if @config[:log_selected_fragments]
         | 
| 94 | 
            +
             | 
| 95 | 
            +
                    prompt_output
         | 
| 96 | 
            +
                  end
         | 
| 97 | 
            +
             | 
| 98 | 
            +
                  # Clear cached indexes (useful for testing or when files change)
         | 
| 99 | 
            +
                  def clear_cache
         | 
| 100 | 
            +
                    @style_guide_indexer = nil
         | 
| 101 | 
            +
                    @template_indexer = nil
         | 
| 102 | 
            +
                    @fragmenter = nil
         | 
| 103 | 
            +
                    @stats.reset!
         | 
| 104 | 
            +
                  end
         | 
| 105 | 
            +
             | 
| 106 | 
            +
                  # Get optimization statistics
         | 
| 107 | 
            +
                  #
         | 
| 108 | 
            +
                  # @return [Hash] Statistics about optimization runs
         | 
| 109 | 
            +
                  def statistics
         | 
| 110 | 
            +
                    @stats.summary
         | 
| 111 | 
            +
                  end
         | 
| 112 | 
            +
             | 
| 113 | 
            +
                  private
         | 
| 114 | 
            +
             | 
| 115 | 
            +
                  # Index all fragment sources
         | 
| 116 | 
            +
                  #
         | 
| 117 | 
            +
                  # @param affected_files [Array<String>] Files to fragment
         | 
| 118 | 
            +
                  # @return [Array] All fragments from all sources
         | 
| 119 | 
            +
                  def index_all_fragments(affected_files)
         | 
| 120 | 
            +
                    fragments = []
         | 
| 121 | 
            +
             | 
| 122 | 
            +
                    # Style guide fragments
         | 
| 123 | 
            +
                    style_guide_indexer.index!
         | 
| 124 | 
            +
                    fragments.concat(style_guide_indexer.fragments)
         | 
| 125 | 
            +
             | 
| 126 | 
            +
                    # Template fragments
         | 
| 127 | 
            +
                    template_indexer.index!
         | 
| 128 | 
            +
                    fragments.concat(template_indexer.templates)
         | 
| 129 | 
            +
             | 
| 130 | 
            +
                    # Source code fragments (only for affected files)
         | 
| 131 | 
            +
                    if affected_files && !affected_files.empty?
         | 
| 132 | 
            +
                      code_fragments = fragmenter.fragment_files(affected_files)
         | 
| 133 | 
            +
                      fragments.concat(code_fragments)
         | 
| 134 | 
            +
                    end
         | 
| 135 | 
            +
             | 
| 136 | 
            +
                    fragments
         | 
| 137 | 
            +
                  end
         | 
| 138 | 
            +
             | 
| 139 | 
            +
                  # Score all fragments against task context
         | 
| 140 | 
            +
                  #
         | 
| 141 | 
            +
                  # @param fragments [Array] Fragments to score
         | 
| 142 | 
            +
                  # @param context [TaskContext] Task context
         | 
| 143 | 
            +
                  # @return [Array<Hash>] Scored fragments
         | 
| 144 | 
            +
                  def score_fragments(fragments, context)
         | 
| 145 | 
            +
                    scorer.score_fragments(fragments, context)
         | 
| 146 | 
            +
                  end
         | 
| 147 | 
            +
             | 
| 148 | 
            +
                  # Compose optimal context within budget
         | 
| 149 | 
            +
                  #
         | 
| 150 | 
            +
                  # @param scored_fragments [Array<Hash>] Scored fragments
         | 
| 151 | 
            +
                  # @param max_tokens [Integer] Token budget
         | 
| 152 | 
            +
                  # @param thresholds [Hash] Type-specific thresholds
         | 
| 153 | 
            +
                  # @return [CompositionResult] Selected fragments
         | 
| 154 | 
            +
                  def compose_context(scored_fragments, max_tokens, thresholds)
         | 
| 155 | 
            +
                    composer(max_tokens).compose(scored_fragments, thresholds: thresholds)
         | 
| 156 | 
            +
                  end
         | 
| 157 | 
            +
             | 
| 158 | 
            +
                  # Build final prompt from selected fragments
         | 
| 159 | 
            +
                  #
         | 
| 160 | 
            +
                  # @param task_context [TaskContext] Task context
         | 
| 161 | 
            +
                  # @param composition_result [CompositionResult] Selected fragments
         | 
| 162 | 
            +
                  # @param options [Hash] Build options
         | 
| 163 | 
            +
                  # @return [PromptOutput] Final prompt
         | 
| 164 | 
            +
                  def build_prompt(task_context, composition_result, options)
         | 
| 165 | 
            +
                    builder.build(task_context, composition_result, options)
         | 
| 166 | 
            +
                  end
         | 
| 167 | 
            +
             | 
| 168 | 
            +
                  # Get or create style guide indexer (cached)
         | 
| 169 | 
            +
                  def style_guide_indexer
         | 
| 170 | 
            +
                    @style_guide_indexer ||= StyleGuideIndexer.new(project_dir: @project_dir)
         | 
| 171 | 
            +
                  end
         | 
| 172 | 
            +
             | 
| 173 | 
            +
                  # Get or create template indexer (cached)
         | 
| 174 | 
            +
                  def template_indexer
         | 
| 175 | 
            +
                    @template_indexer ||= TemplateIndexer.new(project_dir: @project_dir)
         | 
| 176 | 
            +
                  end
         | 
| 177 | 
            +
             | 
| 178 | 
            +
                  # Get or create source code fragmenter (cached)
         | 
| 179 | 
            +
                  def fragmenter
         | 
| 180 | 
            +
                    @fragmenter ||= SourceCodeFragmenter.new(project_dir: @project_dir)
         | 
| 181 | 
            +
                  end
         | 
| 182 | 
            +
             | 
| 183 | 
            +
                  # Get or create relevance scorer (cached)
         | 
| 184 | 
            +
                  def scorer
         | 
| 185 | 
            +
                    @scorer ||= RelevanceScorer.new
         | 
| 186 | 
            +
                  end
         | 
| 187 | 
            +
             | 
| 188 | 
            +
                  # Get or create context composer (cached, but with max_tokens)
         | 
| 189 | 
            +
                  def composer(max_tokens = @config[:max_tokens])
         | 
| 190 | 
            +
                    ContextComposer.new(max_tokens: max_tokens)
         | 
| 191 | 
            +
                  end
         | 
| 192 | 
            +
             | 
| 193 | 
            +
                  # Get or create prompt builder (cached)
         | 
| 194 | 
            +
                  def builder
         | 
| 195 | 
            +
                    @builder ||= PromptBuilder.new
         | 
| 196 | 
            +
                  end
         | 
| 197 | 
            +
             | 
| 198 | 
            +
                  # Default configuration
         | 
| 199 | 
            +
                  def default_config
         | 
| 200 | 
            +
                    {
         | 
| 201 | 
            +
                      enabled: false,
         | 
| 202 | 
            +
                      max_tokens: 16000,
         | 
| 203 | 
            +
                      include_threshold: {
         | 
| 204 | 
            +
                        style_guide: 0.75,
         | 
| 205 | 
            +
                        templates: 0.8,
         | 
| 206 | 
            +
                        source: 0.7
         | 
| 207 | 
            +
                      },
         | 
| 208 | 
            +
                      dynamic_adjustment: false,
         | 
| 209 | 
            +
                      log_selected_fragments: false
         | 
| 210 | 
            +
                    }
         | 
| 211 | 
            +
                  end
         | 
| 212 | 
            +
             | 
| 213 | 
            +
                  # Log optimization result
         | 
| 214 | 
            +
                  def log_optimization_result(prompt_output)
         | 
| 215 | 
            +
                    Aidp.log_info(
         | 
| 216 | 
            +
                      "prompt_optimizer",
         | 
| 217 | 
            +
                      "Optimized prompt generated",
         | 
| 218 | 
            +
                      selected_fragments: prompt_output.composition_result.selected_count,
         | 
| 219 | 
            +
                      excluded_fragments: prompt_output.composition_result.excluded_count,
         | 
| 220 | 
            +
                      total_tokens: prompt_output.estimated_tokens,
         | 
| 221 | 
            +
                      budget_utilization: prompt_output.composition_result.budget_utilization
         | 
| 222 | 
            +
                    )
         | 
| 223 | 
            +
                  end
         | 
| 224 | 
            +
                end
         | 
| 225 | 
            +
             | 
| 226 | 
            +
                # Statistics tracker for optimizer
         | 
| 227 | 
            +
                #
         | 
| 228 | 
            +
                # Tracks metrics across optimization runs for monitoring
         | 
| 229 | 
            +
                # and debugging prompt optimization performance
         | 
| 230 | 
            +
                class OptimizerStats
         | 
| 231 | 
            +
                  attr_reader :runs_count,
         | 
| 232 | 
            +
                    :total_fragments_indexed,
         | 
| 233 | 
            +
                    :total_fragments_scored,
         | 
| 234 | 
            +
                    :total_fragments_selected,
         | 
| 235 | 
            +
                    :total_fragments_excluded,
         | 
| 236 | 
            +
                    :total_tokens_used,
         | 
| 237 | 
            +
                    :total_optimization_time
         | 
| 238 | 
            +
             | 
| 239 | 
            +
                  def initialize
         | 
| 240 | 
            +
                    reset!
         | 
| 241 | 
            +
                  end
         | 
| 242 | 
            +
             | 
| 243 | 
            +
                  # Reset all statistics
         | 
| 244 | 
            +
                  def reset!
         | 
| 245 | 
            +
                    @runs_count = 0
         | 
| 246 | 
            +
                    @total_fragments_indexed = 0
         | 
| 247 | 
            +
                    @total_fragments_scored = 0
         | 
| 248 | 
            +
                    @total_fragments_selected = 0
         | 
| 249 | 
            +
                    @total_fragments_excluded = 0
         | 
| 250 | 
            +
                    @total_tokens_used = 0
         | 
| 251 | 
            +
                    @total_optimization_time = 0.0
         | 
| 252 | 
            +
                    @budget_utilizations = []
         | 
| 253 | 
            +
                  end
         | 
| 254 | 
            +
             | 
| 255 | 
            +
                  # Record fragments indexed
         | 
| 256 | 
            +
                  def record_fragments_indexed(count)
         | 
| 257 | 
            +
                    @total_fragments_indexed += count
         | 
| 258 | 
            +
                  end
         | 
| 259 | 
            +
             | 
| 260 | 
            +
                  # Record fragments scored
         | 
| 261 | 
            +
                  def record_fragments_scored(count)
         | 
| 262 | 
            +
                    @total_fragments_scored += count
         | 
| 263 | 
            +
                  end
         | 
| 264 | 
            +
             | 
| 265 | 
            +
                  # Record fragments selected
         | 
| 266 | 
            +
                  def record_fragments_selected(count)
         | 
| 267 | 
            +
                    @total_fragments_selected += count
         | 
| 268 | 
            +
                    @runs_count += 1
         | 
| 269 | 
            +
                  end
         | 
| 270 | 
            +
             | 
| 271 | 
            +
                  # Record fragments excluded
         | 
| 272 | 
            +
                  def record_fragments_excluded(count)
         | 
| 273 | 
            +
                    @total_fragments_excluded += count
         | 
| 274 | 
            +
                  end
         | 
| 275 | 
            +
             | 
| 276 | 
            +
                  # Record tokens used
         | 
| 277 | 
            +
                  def record_tokens_used(tokens)
         | 
| 278 | 
            +
                    @total_tokens_used += tokens
         | 
| 279 | 
            +
                  end
         | 
| 280 | 
            +
             | 
| 281 | 
            +
                  # Record budget utilization
         | 
| 282 | 
            +
                  def record_budget_utilization(utilization)
         | 
| 283 | 
            +
                    @budget_utilizations << utilization
         | 
| 284 | 
            +
                  end
         | 
| 285 | 
            +
             | 
| 286 | 
            +
                  # Record optimization time
         | 
| 287 | 
            +
                  def record_optimization_time(seconds)
         | 
| 288 | 
            +
                    @total_optimization_time += seconds
         | 
| 289 | 
            +
                  end
         | 
| 290 | 
            +
             | 
| 291 | 
            +
                  # Get average budget utilization
         | 
| 292 | 
            +
                  def average_budget_utilization
         | 
| 293 | 
            +
                    return 0.0 if @budget_utilizations.empty?
         | 
| 294 | 
            +
             | 
| 295 | 
            +
                    (@budget_utilizations.sum / @budget_utilizations.count.to_f).round(2)
         | 
| 296 | 
            +
                  end
         | 
| 297 | 
            +
             | 
| 298 | 
            +
                  # Get average optimization time
         | 
| 299 | 
            +
                  def average_optimization_time
         | 
| 300 | 
            +
                    return 0.0 if @runs_count.zero?
         | 
| 301 | 
            +
             | 
| 302 | 
            +
                    (@total_optimization_time / @runs_count).round(4)
         | 
| 303 | 
            +
                  end
         | 
| 304 | 
            +
             | 
| 305 | 
            +
                  # Get average fragments selected per run
         | 
| 306 | 
            +
                  def average_fragments_selected
         | 
| 307 | 
            +
                    return 0.0 if @runs_count.zero?
         | 
| 308 | 
            +
             | 
| 309 | 
            +
                    (@total_fragments_selected.to_f / @runs_count).round(2)
         | 
| 310 | 
            +
                  end
         | 
| 311 | 
            +
             | 
| 312 | 
            +
                  # Get summary statistics
         | 
| 313 | 
            +
                  #
         | 
| 314 | 
            +
                  # @return [Hash] Statistics summary
         | 
| 315 | 
            +
                  def summary
         | 
| 316 | 
            +
                    {
         | 
| 317 | 
            +
                      runs_count: @runs_count,
         | 
| 318 | 
            +
                      total_fragments_indexed: @total_fragments_indexed,
         | 
| 319 | 
            +
                      total_fragments_scored: @total_fragments_scored,
         | 
| 320 | 
            +
                      total_fragments_selected: @total_fragments_selected,
         | 
| 321 | 
            +
                      total_fragments_excluded: @total_fragments_excluded,
         | 
| 322 | 
            +
                      total_tokens_used: @total_tokens_used,
         | 
| 323 | 
            +
                      average_fragments_selected: average_fragments_selected,
         | 
| 324 | 
            +
                      average_budget_utilization: average_budget_utilization,
         | 
| 325 | 
            +
                      average_optimization_time_ms: (average_optimization_time * 1000).round(2)
         | 
| 326 | 
            +
                    }
         | 
| 327 | 
            +
                  end
         | 
| 328 | 
            +
             | 
| 329 | 
            +
                  def to_s
         | 
| 330 | 
            +
                    avg_time_ms = (average_optimization_time * 1000).round(2)
         | 
| 331 | 
            +
                    "OptimizerStats<#{@runs_count} runs, #{average_fragments_selected} avg fragments, #{avg_time_ms}ms avg time>"
         | 
| 332 | 
            +
                  end
         | 
| 333 | 
            +
                end
         | 
| 334 | 
            +
              end
         | 
| 335 | 
            +
            end
         | 
| @@ -0,0 +1,309 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Aidp
         | 
| 4 | 
            +
              module PromptOptimization
         | 
| 5 | 
            +
                # Builds the final PROMPT.md from selected fragments
         | 
| 6 | 
            +
                #
         | 
| 7 | 
            +
                # Assembles an optimized prompt that includes:
         | 
| 8 | 
            +
                # - Task description
         | 
| 9 | 
            +
                # - Relevant style guide sections
         | 
| 10 | 
            +
                # - Selected template fragments
         | 
| 11 | 
            +
                # - Code context
         | 
| 12 | 
            +
                # - Implementation guidance
         | 
| 13 | 
            +
                #
         | 
| 14 | 
            +
                # @example Basic usage
         | 
| 15 | 
            +
                #   builder = PromptBuilder.new
         | 
| 16 | 
            +
                #   prompt = builder.build(task_context, composition_result, options)
         | 
| 17 | 
            +
                class PromptBuilder
         | 
| 18 | 
            +
                  # Build prompt from composition result
         | 
| 19 | 
            +
                  #
         | 
| 20 | 
            +
                  # @param task_context [TaskContext] Task context
         | 
| 21 | 
            +
                  # @param composition_result [CompositionResult] Selected fragments
         | 
| 22 | 
            +
                  # @param options [Hash] Build options
         | 
| 23 | 
            +
                  # @option options [Boolean] :include_metadata Include selection metadata
         | 
| 24 | 
            +
                  # @option options [Boolean] :include_stats Include composition statistics
         | 
| 25 | 
            +
                  # @return [PromptOutput] Built prompt with metadata
         | 
| 26 | 
            +
                  def build(task_context, composition_result, options = {})
         | 
| 27 | 
            +
                    sections = []
         | 
| 28 | 
            +
             | 
| 29 | 
            +
                    # Task section
         | 
| 30 | 
            +
                    sections << build_task_section(task_context)
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                    # Group fragments by type
         | 
| 33 | 
            +
                    style_guide_fragments = composition_result.fragments_by_type(:style_guide)
         | 
| 34 | 
            +
                    template_fragments = composition_result.fragments_by_type(:template)
         | 
| 35 | 
            +
                    code_fragments = composition_result.fragments_by_type(:code)
         | 
| 36 | 
            +
             | 
| 37 | 
            +
                    # Add relevant sections if they have content
         | 
| 38 | 
            +
                    sections << build_style_guide_section(style_guide_fragments) unless style_guide_fragments.empty?
         | 
| 39 | 
            +
                    sections << build_template_section(template_fragments) unless template_fragments.empty?
         | 
| 40 | 
            +
                    sections << build_code_section(code_fragments) unless code_fragments.empty?
         | 
| 41 | 
            +
             | 
| 42 | 
            +
                    # Optional metadata
         | 
| 43 | 
            +
                    if options[:include_metadata]
         | 
| 44 | 
            +
                      sections << build_metadata_section(composition_result)
         | 
| 45 | 
            +
                    end
         | 
| 46 | 
            +
             | 
| 47 | 
            +
                    content = sections.join("\n\n---\n\n")
         | 
| 48 | 
            +
             | 
| 49 | 
            +
                    PromptOutput.new(
         | 
| 50 | 
            +
                      content: content,
         | 
| 51 | 
            +
                      composition_result: composition_result,
         | 
| 52 | 
            +
                      task_context: task_context,
         | 
| 53 | 
            +
                      metadata: build_metadata(composition_result, options)
         | 
| 54 | 
            +
                    )
         | 
| 55 | 
            +
                  end
         | 
| 56 | 
            +
             | 
| 57 | 
            +
                  private
         | 
| 58 | 
            +
             | 
| 59 | 
            +
                  # Build task description section
         | 
| 60 | 
            +
                  #
         | 
| 61 | 
            +
                  # @param task_context [TaskContext] Task context
         | 
| 62 | 
            +
                  # @return [String] Task section markdown
         | 
| 63 | 
            +
                  def build_task_section(task_context)
         | 
| 64 | 
            +
                    lines = ["# Task"]
         | 
| 65 | 
            +
             | 
| 66 | 
            +
                    if task_context.task_type
         | 
| 67 | 
            +
                      lines << "\n**Type**: #{task_context.task_type}"
         | 
| 68 | 
            +
                    end
         | 
| 69 | 
            +
             | 
| 70 | 
            +
                    if task_context.description
         | 
| 71 | 
            +
                      lines << "\n## Description"
         | 
| 72 | 
            +
                      lines << "\n#{task_context.description}"
         | 
| 73 | 
            +
                    end
         | 
| 74 | 
            +
             | 
| 75 | 
            +
                    if task_context.affected_files && !task_context.affected_files.empty?
         | 
| 76 | 
            +
                      lines << "\n## Affected Files"
         | 
| 77 | 
            +
                      task_context.affected_files.each do |file|
         | 
| 78 | 
            +
                        lines << "- `#{file}`"
         | 
| 79 | 
            +
                      end
         | 
| 80 | 
            +
                    end
         | 
| 81 | 
            +
             | 
| 82 | 
            +
                    if task_context.step_name
         | 
| 83 | 
            +
                      lines << "\n## Current Step"
         | 
| 84 | 
            +
                      lines << "\n#{task_context.step_name}"
         | 
| 85 | 
            +
                    end
         | 
| 86 | 
            +
             | 
| 87 | 
            +
                    lines.join("\n")
         | 
| 88 | 
            +
                  end
         | 
| 89 | 
            +
             | 
| 90 | 
            +
                  # Build style guide section
         | 
| 91 | 
            +
                  #
         | 
| 92 | 
            +
                  # @param fragments [Array<Hash>] Style guide fragments
         | 
| 93 | 
            +
                  # @return [String] Style guide section markdown
         | 
| 94 | 
            +
                  def build_style_guide_section(fragments)
         | 
| 95 | 
            +
                    lines = ["# Relevant Style Guidelines"]
         | 
| 96 | 
            +
             | 
| 97 | 
            +
                    lines << "\nThe following style guide sections are relevant to this task:"
         | 
| 98 | 
            +
                    lines << ""
         | 
| 99 | 
            +
             | 
| 100 | 
            +
                    fragments.each do |item|
         | 
| 101 | 
            +
                      fragment = item[:fragment]
         | 
| 102 | 
            +
                      score = item[:score]
         | 
| 103 | 
            +
             | 
| 104 | 
            +
                      lines << "## #{fragment.heading}"
         | 
| 105 | 
            +
                      lines << ""
         | 
| 106 | 
            +
                      lines << fragment.content
         | 
| 107 | 
            +
                      lines << ""
         | 
| 108 | 
            +
             | 
| 109 | 
            +
                      if score >= 0.9
         | 
| 110 | 
            +
                        lines << "_[Critical: Relevance score #{(score * 100).round}%]_"
         | 
| 111 | 
            +
                        lines << ""
         | 
| 112 | 
            +
                      end
         | 
| 113 | 
            +
                    end
         | 
| 114 | 
            +
             | 
| 115 | 
            +
                    lines.join("\n")
         | 
| 116 | 
            +
                  end
         | 
| 117 | 
            +
             | 
| 118 | 
            +
                  # Build template section
         | 
| 119 | 
            +
                  #
         | 
| 120 | 
            +
                  # @param fragments [Array<Hash>] Template fragments
         | 
| 121 | 
            +
                  # @return [String] Template section markdown
         | 
| 122 | 
            +
                  def build_template_section(fragments)
         | 
| 123 | 
            +
                    lines = ["# Template Guidance"]
         | 
| 124 | 
            +
             | 
| 125 | 
            +
                    lines << "\nThe following templates provide guidance for this type of work:"
         | 
| 126 | 
            +
                    lines << ""
         | 
| 127 | 
            +
             | 
| 128 | 
            +
                    fragments.each do |item|
         | 
| 129 | 
            +
                      fragment = item[:fragment]
         | 
| 130 | 
            +
             | 
| 131 | 
            +
                      lines << "## #{fragment.name}"
         | 
| 132 | 
            +
                      lines << ""
         | 
| 133 | 
            +
                      lines << "**Category**: #{fragment.category}"
         | 
| 134 | 
            +
                      lines << ""
         | 
| 135 | 
            +
                      lines << fragment.content
         | 
| 136 | 
            +
                      lines << ""
         | 
| 137 | 
            +
                    end
         | 
| 138 | 
            +
             | 
| 139 | 
            +
                    lines.join("\n")
         | 
| 140 | 
            +
                  end
         | 
| 141 | 
            +
             | 
| 142 | 
            +
                  # Build code context section
         | 
| 143 | 
            +
                  #
         | 
| 144 | 
            +
                  # @param fragments [Array<Hash>] Code fragments
         | 
| 145 | 
            +
                  # @return [String] Code section markdown
         | 
| 146 | 
            +
                  def build_code_section(fragments)
         | 
| 147 | 
            +
                    lines = ["# Code Context"]
         | 
| 148 | 
            +
             | 
| 149 | 
            +
                    lines << "\nRelevant code from affected files:"
         | 
| 150 | 
            +
                    lines << ""
         | 
| 151 | 
            +
             | 
| 152 | 
            +
                    # Group by file
         | 
| 153 | 
            +
                    by_file = fragments.group_by { |item| item[:fragment].file_path }
         | 
| 154 | 
            +
             | 
| 155 | 
            +
                    by_file.each do |file_path, file_fragments|
         | 
| 156 | 
            +
                      relative_path = file_fragments.first[:fragment].respond_to?(:relative_path) ?
         | 
| 157 | 
            +
                                     file_fragments.first[:fragment].relative_path(File.dirname(file_path)) :
         | 
| 158 | 
            +
                                     File.basename(file_path)
         | 
| 159 | 
            +
             | 
| 160 | 
            +
                      lines << "## `#{relative_path}`"
         | 
| 161 | 
            +
                      lines << ""
         | 
| 162 | 
            +
             | 
| 163 | 
            +
                      file_fragments.each do |item|
         | 
| 164 | 
            +
                        fragment = item[:fragment]
         | 
| 165 | 
            +
             | 
| 166 | 
            +
                        lines << "### #{fragment.type}: #{fragment.name} (lines #{fragment.line_start}-#{fragment.line_end})"
         | 
| 167 | 
            +
                        lines << ""
         | 
| 168 | 
            +
                        lines << "```ruby"
         | 
| 169 | 
            +
                        lines << fragment.content
         | 
| 170 | 
            +
                        lines << "```"
         | 
| 171 | 
            +
                        lines << ""
         | 
| 172 | 
            +
                      end
         | 
| 173 | 
            +
                    end
         | 
| 174 | 
            +
             | 
| 175 | 
            +
                    lines.join("\n")
         | 
| 176 | 
            +
                  end
         | 
| 177 | 
            +
             | 
| 178 | 
            +
                  # Build metadata section
         | 
| 179 | 
            +
                  #
         | 
| 180 | 
            +
                  # @param composition_result [CompositionResult] Composition result
         | 
| 181 | 
            +
                  # @return [String] Metadata section markdown
         | 
| 182 | 
            +
                  def build_metadata_section(composition_result)
         | 
| 183 | 
            +
                    lines = ["# Prompt Optimization Metadata"]
         | 
| 184 | 
            +
                    lines << ""
         | 
| 185 | 
            +
                    lines << "_This section shows how the prompt was optimized. Remove before sending to model._"
         | 
| 186 | 
            +
                    lines << ""
         | 
| 187 | 
            +
             | 
| 188 | 
            +
                    summary = composition_result.summary
         | 
| 189 | 
            +
             | 
| 190 | 
            +
                    lines << "## Selection Statistics"
         | 
| 191 | 
            +
                    lines << ""
         | 
| 192 | 
            +
                    lines << "- **Fragments Selected**: #{summary[:selected_count]}"
         | 
| 193 | 
            +
                    lines << "- **Fragments Excluded**: #{summary[:excluded_count]}"
         | 
| 194 | 
            +
                    lines << "- **Token Budget**: #{summary[:total_tokens]} / #{summary[:budget]} (#{summary[:utilization]}%)"
         | 
| 195 | 
            +
                    lines << "- **Average Relevance Score**: #{(summary[:average_score] * 100).round}%"
         | 
| 196 | 
            +
                    lines << ""
         | 
| 197 | 
            +
             | 
| 198 | 
            +
                    lines << "## Fragments by Type"
         | 
| 199 | 
            +
                    lines << ""
         | 
| 200 | 
            +
                    lines << "- **Style Guide Sections**: #{summary[:by_type][:style_guide]}"
         | 
| 201 | 
            +
                    lines << "- **Templates**: #{summary[:by_type][:templates]}"
         | 
| 202 | 
            +
                    lines << "- **Code Fragments**: #{summary[:by_type][:code]}"
         | 
| 203 | 
            +
                    lines << ""
         | 
| 204 | 
            +
             | 
| 205 | 
            +
                    lines.join("\n")
         | 
| 206 | 
            +
                  end
         | 
| 207 | 
            +
             | 
| 208 | 
            +
                  # Build metadata hash
         | 
| 209 | 
            +
                  #
         | 
| 210 | 
            +
                  # @param composition_result [CompositionResult] Composition result
         | 
| 211 | 
            +
                  # @param options [Hash] Build options
         | 
| 212 | 
            +
                  # @return [Hash] Metadata
         | 
| 213 | 
            +
                  def build_metadata(composition_result, options)
         | 
| 214 | 
            +
                    {
         | 
| 215 | 
            +
                      selected_count: composition_result.selected_count,
         | 
| 216 | 
            +
                      excluded_count: composition_result.excluded_count,
         | 
| 217 | 
            +
                      total_tokens: composition_result.total_tokens,
         | 
| 218 | 
            +
                      budget: composition_result.budget,
         | 
| 219 | 
            +
                      utilization: composition_result.budget_utilization,
         | 
| 220 | 
            +
                      average_score: composition_result.average_score,
         | 
| 221 | 
            +
                      timestamp: Time.now.iso8601,
         | 
| 222 | 
            +
                      include_metadata: options[:include_metadata] || false
         | 
| 223 | 
            +
                    }
         | 
| 224 | 
            +
                  end
         | 
| 225 | 
            +
                end
         | 
| 226 | 
            +
             | 
| 227 | 
            +
                # Output of prompt building
         | 
| 228 | 
            +
                #
         | 
| 229 | 
            +
                # Contains the built prompt content along with metadata
         | 
| 230 | 
            +
                # about the composition and selection process
         | 
| 231 | 
            +
                class PromptOutput
         | 
| 232 | 
            +
                  attr_reader :content, :composition_result, :task_context, :metadata
         | 
| 233 | 
            +
             | 
| 234 | 
            +
                  def initialize(content:, composition_result:, task_context:, metadata:)
         | 
| 235 | 
            +
                    @content = content
         | 
| 236 | 
            +
                    @composition_result = composition_result
         | 
| 237 | 
            +
                    @task_context = task_context
         | 
| 238 | 
            +
                    @metadata = metadata
         | 
| 239 | 
            +
                  end
         | 
| 240 | 
            +
             | 
| 241 | 
            +
                  # Get content length in characters
         | 
| 242 | 
            +
                  #
         | 
| 243 | 
            +
                  # @return [Integer] Character count
         | 
| 244 | 
            +
                  def size
         | 
| 245 | 
            +
                    @content.length
         | 
| 246 | 
            +
                  end
         | 
| 247 | 
            +
             | 
| 248 | 
            +
                  # Estimate token count for the prompt
         | 
| 249 | 
            +
                  #
         | 
| 250 | 
            +
                  # @return [Integer] Estimated tokens
         | 
| 251 | 
            +
                  def estimated_tokens
         | 
| 252 | 
            +
                    (size / 4.0).ceil
         | 
| 253 | 
            +
                  end
         | 
| 254 | 
            +
             | 
| 255 | 
            +
                  # Write prompt to file
         | 
| 256 | 
            +
                  #
         | 
| 257 | 
            +
                  # @param file_path [String] Path to write prompt
         | 
| 258 | 
            +
                  def write_to_file(file_path)
         | 
| 259 | 
            +
                    File.write(file_path, @content)
         | 
| 260 | 
            +
                    Aidp.log_info("prompt_builder", "Wrote optimized prompt", path: file_path, tokens: estimated_tokens)
         | 
| 261 | 
            +
                  end
         | 
| 262 | 
            +
             | 
| 263 | 
            +
                  # Get fragment selection report
         | 
| 264 | 
            +
                  #
         | 
| 265 | 
            +
                  # @return [String] Human-readable report
         | 
| 266 | 
            +
                  def selection_report
         | 
| 267 | 
            +
                    lines = ["# Prompt Optimization Report"]
         | 
| 268 | 
            +
                    lines << ""
         | 
| 269 | 
            +
                    lines << "Generated at: #{@metadata[:timestamp]}"
         | 
| 270 | 
            +
                    lines << ""
         | 
| 271 | 
            +
             | 
| 272 | 
            +
                    lines << "## Task Context"
         | 
| 273 | 
            +
                    lines << "- Type: #{@task_context.task_type || "N/A"}"
         | 
| 274 | 
            +
                    lines << "- Step: #{@task_context.step_name || "N/A"}"
         | 
| 275 | 
            +
                    if @task_context.affected_files && !@task_context.affected_files.empty?
         | 
| 276 | 
            +
                      lines << "- Affected Files: #{@task_context.affected_files.join(", ")}"
         | 
| 277 | 
            +
                    end
         | 
| 278 | 
            +
                    lines << ""
         | 
| 279 | 
            +
             | 
| 280 | 
            +
                    lines << "## Composition Statistics"
         | 
| 281 | 
            +
                    lines << "- Selected: #{@metadata[:selected_count]} fragments"
         | 
| 282 | 
            +
                    lines << "- Excluded: #{@metadata[:excluded_count]} fragments"
         | 
| 283 | 
            +
                    lines << "- Tokens: #{@metadata[:total_tokens]} / #{@metadata[:budget]} (#{@metadata[:utilization]}%)"
         | 
| 284 | 
            +
                    lines << "- Avg Score: #{(@metadata[:average_score] * 100).round}%"
         | 
| 285 | 
            +
                    lines << ""
         | 
| 286 | 
            +
             | 
| 287 | 
            +
                    lines << "## Selected Fragments"
         | 
| 288 | 
            +
                    @composition_result.selected_fragments.each do |item|
         | 
| 289 | 
            +
                      fragment = item[:fragment]
         | 
| 290 | 
            +
                      score = item[:score]
         | 
| 291 | 
            +
             | 
| 292 | 
            +
                      if fragment.respond_to?(:heading)
         | 
| 293 | 
            +
                        lines << "- #{fragment.heading} (#{(score * 100).round}%)"
         | 
| 294 | 
            +
                      elsif fragment.respond_to?(:name)
         | 
| 295 | 
            +
                        lines << "- #{fragment.name} (#{(score * 100).round}%)"
         | 
| 296 | 
            +
                      elsif fragment.respond_to?(:id)
         | 
| 297 | 
            +
                        lines << "- #{fragment.id} (#{(score * 100).round}%)"
         | 
| 298 | 
            +
                      end
         | 
| 299 | 
            +
                    end
         | 
| 300 | 
            +
             | 
| 301 | 
            +
                    lines.join("\n")
         | 
| 302 | 
            +
                  end
         | 
| 303 | 
            +
             | 
| 304 | 
            +
                  def to_s
         | 
| 305 | 
            +
                    "PromptOutput<#{estimated_tokens} tokens, #{@composition_result.selected_count} fragments>"
         | 
| 306 | 
            +
                  end
         | 
| 307 | 
            +
                end
         | 
| 308 | 
            +
              end
         | 
| 309 | 
            +
            end
         |