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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +69 -0
  3. data/lib/aidp/cli.rb +43 -2
  4. data/lib/aidp/config.rb +9 -14
  5. data/lib/aidp/execute/prompt_manager.rb +128 -1
  6. data/lib/aidp/execute/repl_macros.rb +555 -0
  7. data/lib/aidp/execute/work_loop_runner.rb +108 -1
  8. data/lib/aidp/harness/ai_decision_engine.rb +376 -0
  9. data/lib/aidp/harness/capability_registry.rb +273 -0
  10. data/lib/aidp/harness/config_schema.rb +305 -1
  11. data/lib/aidp/harness/configuration.rb +452 -0
  12. data/lib/aidp/harness/enhanced_runner.rb +7 -1
  13. data/lib/aidp/harness/provider_factory.rb +0 -2
  14. data/lib/aidp/harness/runner.rb +7 -1
  15. data/lib/aidp/harness/thinking_depth_manager.rb +335 -0
  16. data/lib/aidp/harness/zfc_condition_detector.rb +395 -0
  17. data/lib/aidp/init/devcontainer_generator.rb +274 -0
  18. data/lib/aidp/init/runner.rb +37 -10
  19. data/lib/aidp/init.rb +1 -0
  20. data/lib/aidp/prompt_optimization/context_composer.rb +286 -0
  21. data/lib/aidp/prompt_optimization/optimizer.rb +335 -0
  22. data/lib/aidp/prompt_optimization/prompt_builder.rb +309 -0
  23. data/lib/aidp/prompt_optimization/relevance_scorer.rb +256 -0
  24. data/lib/aidp/prompt_optimization/source_code_fragmenter.rb +308 -0
  25. data/lib/aidp/prompt_optimization/style_guide_indexer.rb +240 -0
  26. data/lib/aidp/prompt_optimization/template_indexer.rb +250 -0
  27. data/lib/aidp/provider_manager.rb +0 -2
  28. data/lib/aidp/providers/anthropic.rb +19 -0
  29. data/lib/aidp/setup/wizard.rb +299 -4
  30. data/lib/aidp/utils/devcontainer_detector.rb +166 -0
  31. data/lib/aidp/version.rb +1 -1
  32. data/lib/aidp/watch/build_processor.rb +72 -6
  33. data/lib/aidp/watch/repository_client.rb +2 -1
  34. data/lib/aidp.rb +0 -1
  35. data/templates/aidp.yml.example +128 -0
  36. metadata +14 -2
  37. 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