aidp 0.17.1 → 0.19.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/terminal_io.rb +5 -2
- data/lib/aidp/cli.rb +43 -2
- data/lib/aidp/config.rb +9 -14
- data/lib/aidp/execute/agent_signal_parser.rb +20 -0
- data/lib/aidp/execute/persistent_tasklist.rb +220 -0
- data/lib/aidp/execute/prompt_manager.rb +128 -1
- data/lib/aidp/execute/repl_macros.rb +719 -0
- data/lib/aidp/execute/work_loop_runner.rb +162 -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 +1 -1
- data/templates/aidp.yml.example +128 -0
- metadata +15 -2
- data/lib/aidp/providers/macos_ui.rb +0 -102
|
@@ -46,11 +46,12 @@ module Aidp
|
|
|
46
46
|
@project_dir = project_dir
|
|
47
47
|
@provider_manager = provider_manager
|
|
48
48
|
@config = config
|
|
49
|
-
@prompt_manager = PromptManager.new(project_dir)
|
|
49
|
+
@prompt_manager = PromptManager.new(project_dir, config: config)
|
|
50
50
|
@test_runner = Aidp::Harness::TestRunner.new(project_dir, config)
|
|
51
51
|
@checkpoint = Checkpoint.new(project_dir)
|
|
52
52
|
@checkpoint_display = CheckpointDisplay.new
|
|
53
53
|
@guard_policy = GuardPolicy.new(project_dir, config.guards_config)
|
|
54
|
+
@persistent_tasklist = PersistentTasklist.new(project_dir)
|
|
54
55
|
@iteration_count = 0
|
|
55
56
|
@step_name = nil
|
|
56
57
|
@options = options
|
|
@@ -74,6 +75,7 @@ module Aidp
|
|
|
74
75
|
display_message(" Flow: Deterministic ↔ Agentic with fix-forward core", type: :info)
|
|
75
76
|
|
|
76
77
|
display_guard_policy_status
|
|
78
|
+
display_pending_tasks
|
|
77
79
|
|
|
78
80
|
@unit_scheduler = WorkLoopUnitScheduler.new(units_config)
|
|
79
81
|
base_context = context.dup
|
|
@@ -148,6 +150,9 @@ module Aidp
|
|
|
148
150
|
transition_to(:apply_patch)
|
|
149
151
|
agent_result = apply_patch
|
|
150
152
|
|
|
153
|
+
# Process agent output for task filing signals
|
|
154
|
+
process_task_filing(agent_result)
|
|
155
|
+
|
|
151
156
|
transition_to(:test)
|
|
152
157
|
test_results = @test_runner.run_tests
|
|
153
158
|
lint_results = @test_runner.run_linters
|
|
@@ -343,6 +348,16 @@ module Aidp
|
|
|
343
348
|
|
|
344
349
|
# Create initial PROMPT.md with all context
|
|
345
350
|
def create_initial_prompt(step_spec, context)
|
|
351
|
+
# Try intelligent prompt optimization first (ZFC-powered)
|
|
352
|
+
if @prompt_manager.optimization_enabled?
|
|
353
|
+
if create_optimized_prompt(step_spec, context)
|
|
354
|
+
return
|
|
355
|
+
end
|
|
356
|
+
# Fallback to traditional prompt on optimization failure
|
|
357
|
+
display_message(" ⚠️ Prompt optimization failed, using traditional approach", type: :warning)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Traditional prompt building (fallback or when optimization disabled)
|
|
346
361
|
template_content = load_template(step_spec["templates"]&.first)
|
|
347
362
|
prd_content = load_prd
|
|
348
363
|
style_guide = load_style_guide
|
|
@@ -364,6 +379,103 @@ module Aidp
|
|
|
364
379
|
display_message(" Created PROMPT.md (#{initial_prompt.length} chars)", type: :info)
|
|
365
380
|
end
|
|
366
381
|
|
|
382
|
+
# Create prompt using intelligent optimization (Zero Framework Cognition)
|
|
383
|
+
# Selects only the most relevant fragments from style guide, templates, and code
|
|
384
|
+
def create_optimized_prompt(step_spec, context)
|
|
385
|
+
user_input = format_user_input(context[:user_input])
|
|
386
|
+
|
|
387
|
+
# Infer task type from step name
|
|
388
|
+
task_type = infer_task_type(step_spec, user_input)
|
|
389
|
+
|
|
390
|
+
# Extract affected files from context or PRD
|
|
391
|
+
affected_files = extract_affected_files(context, user_input)
|
|
392
|
+
|
|
393
|
+
# Build task context for optimizer
|
|
394
|
+
task_context = {
|
|
395
|
+
task_type: task_type,
|
|
396
|
+
description: build_task_description(user_input, context),
|
|
397
|
+
affected_files: affected_files,
|
|
398
|
+
step_name: @step_name,
|
|
399
|
+
tags: extract_tags(user_input, step_spec)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
# Use optimizer to create prompt
|
|
403
|
+
success = @prompt_manager.write_optimized(
|
|
404
|
+
task_context,
|
|
405
|
+
include_metadata: @config.prompt_log_fragments?
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
if success
|
|
409
|
+
stats = @prompt_manager.last_optimization_stats
|
|
410
|
+
display_message(" ✨ Created optimized PROMPT.md", type: :success)
|
|
411
|
+
display_message(" Selected: #{stats.selected_count} fragments, Excluded: #{stats.excluded_count}", type: :info)
|
|
412
|
+
display_message(" Tokens: #{stats.total_tokens} (#{stats.budget_utilization.round(1)}% of budget)", type: :info)
|
|
413
|
+
display_message(" Avg relevance: #{(stats.average_score * 100).round(1)}%", type: :info)
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
success
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Infer task type from step name and context
|
|
420
|
+
def infer_task_type(step_spec, user_input)
|
|
421
|
+
step_name = @step_name.to_s.downcase
|
|
422
|
+
input_lower = user_input.to_s.downcase
|
|
423
|
+
|
|
424
|
+
return :test if step_name.include?("test") || input_lower.include?("test")
|
|
425
|
+
return :bugfix if step_name.include?("fix") || input_lower.include?("fix") || input_lower.include?("bug")
|
|
426
|
+
return :refactor if step_name.include?("refactor") || input_lower.include?("refactor")
|
|
427
|
+
return :analysis if step_name.include?("analyz") || step_name.include?("review")
|
|
428
|
+
|
|
429
|
+
:feature # Default to feature
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Extract files that will be affected by this work
|
|
433
|
+
def extract_affected_files(context, user_input)
|
|
434
|
+
files = []
|
|
435
|
+
|
|
436
|
+
# From user input (e.g., "update lib/user.rb")
|
|
437
|
+
user_input&.scan(/[\w\/]+\.rb/)&.each do |file|
|
|
438
|
+
files << file
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# From deterministic outputs
|
|
442
|
+
context[:deterministic_outputs]&.each do |output|
|
|
443
|
+
if output[:output_path]&.end_with?(".rb")
|
|
444
|
+
files << output[:output_path]
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
files.uniq
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Build task description from context
|
|
452
|
+
def build_task_description(user_input, context)
|
|
453
|
+
parts = []
|
|
454
|
+
parts << user_input if user_input && !user_input.empty?
|
|
455
|
+
parts << context[:previous_agent_summary] if context[:previous_agent_summary]
|
|
456
|
+
parts.join("\n\n")
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# Extract relevant tags from input and spec
|
|
460
|
+
def extract_tags(user_input, step_spec)
|
|
461
|
+
tags = []
|
|
462
|
+
input_lower = user_input.to_s.downcase
|
|
463
|
+
|
|
464
|
+
# Common tags from content
|
|
465
|
+
tags << "testing" if input_lower.include?("test")
|
|
466
|
+
tags << "security" if input_lower.include?("security") || input_lower.include?("auth")
|
|
467
|
+
tags << "api" if input_lower.include?("api") || input_lower.include?("endpoint")
|
|
468
|
+
tags << "database" if input_lower.include?("database") || input_lower.include?("migration")
|
|
469
|
+
tags << "performance" if input_lower.include?("performance") || input_lower.include?("optim")
|
|
470
|
+
|
|
471
|
+
# Tags from step spec
|
|
472
|
+
if step_spec["tags"]
|
|
473
|
+
tags.concat(Array(step_spec["tags"]))
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
tags.uniq
|
|
477
|
+
end
|
|
478
|
+
|
|
367
479
|
def build_initial_prompt_content(template:, prd:, style_guide:, user_input:, step_name:, deterministic_outputs:, previous_agent_summary:)
|
|
368
480
|
parts = []
|
|
369
481
|
|
|
@@ -669,6 +781,55 @@ module Aidp
|
|
|
669
781
|
display_message("")
|
|
670
782
|
end
|
|
671
783
|
|
|
784
|
+
# Display pending tasks from persistent tasklist
|
|
785
|
+
def display_pending_tasks
|
|
786
|
+
pending_tasks = @persistent_tasklist.pending
|
|
787
|
+
return if pending_tasks.empty?
|
|
788
|
+
|
|
789
|
+
display_message("\n📋 Pending Tasks from Previous Sessions:", type: :info)
|
|
790
|
+
|
|
791
|
+
# Show up to 5 most recent pending tasks
|
|
792
|
+
pending_tasks.take(5).each do |task|
|
|
793
|
+
priority_icon = case task.priority
|
|
794
|
+
when :high then "⚠️ "
|
|
795
|
+
when :medium then "○ "
|
|
796
|
+
when :low then "· "
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
age = ((Time.now - task.created_at) / 86400).to_i
|
|
800
|
+
age_str = (age > 0) ? " (#{age}d ago)" : " (today)"
|
|
801
|
+
|
|
802
|
+
display_message(" #{priority_icon}#{task.description}#{age_str}", type: :info)
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
if pending_tasks.size > 5
|
|
806
|
+
display_message(" ... and #{pending_tasks.size - 5} more. Use /tasks list to see all", type: :info)
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
display_message("")
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
# Process agent output for task filing signals
|
|
813
|
+
def process_task_filing(agent_result)
|
|
814
|
+
return unless agent_result && agent_result[:output]
|
|
815
|
+
|
|
816
|
+
filed_tasks = AgentSignalParser.parse_task_filing(agent_result[:output])
|
|
817
|
+
return if filed_tasks.empty?
|
|
818
|
+
|
|
819
|
+
filed_tasks.each do |task_data|
|
|
820
|
+
task = @persistent_tasklist.create(
|
|
821
|
+
task_data[:description],
|
|
822
|
+
priority: task_data[:priority],
|
|
823
|
+
session: @step_name,
|
|
824
|
+
discovered_during: "#{@step_name} iteration #{@iteration_count}",
|
|
825
|
+
tags: task_data[:tags]
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
Aidp.log_info("tasklist", "Filed new task from agent", task_id: task.id, description: task.description)
|
|
829
|
+
display_message("📋 Filed task: #{task.description} (#{task.id})", type: :info)
|
|
830
|
+
end
|
|
831
|
+
end
|
|
832
|
+
|
|
672
833
|
# Validate changes against guard policy
|
|
673
834
|
# Returns validation result with errors if any
|
|
674
835
|
def validate_guard_policy(changed_files = [])
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "provider_factory"
|
|
5
|
+
require_relative "thinking_depth_manager"
|
|
6
|
+
|
|
7
|
+
module Aidp
|
|
8
|
+
module Harness
|
|
9
|
+
# Zero Framework Cognition (ZFC) Decision Engine
|
|
10
|
+
#
|
|
11
|
+
# Delegates semantic analysis and decision-making to AI models instead of
|
|
12
|
+
# using brittle pattern matching, scoring formulas, or heuristic thresholds.
|
|
13
|
+
#
|
|
14
|
+
# @example Basic usage
|
|
15
|
+
# engine = AIDecisionEngine.new(config, provider_manager)
|
|
16
|
+
# result = engine.decide(:condition_detection,
|
|
17
|
+
# context: { error: "Rate limit exceeded" },
|
|
18
|
+
# schema: ConditionSchema,
|
|
19
|
+
# tier: "mini"
|
|
20
|
+
# )
|
|
21
|
+
# # => { condition: "rate_limit", confidence: 0.95, reasoning: "..." }
|
|
22
|
+
#
|
|
23
|
+
# @see docs/ZFC_COMPLIANCE_ASSESSMENT.md
|
|
24
|
+
# @see docs/ZFC_IMPLEMENTATION_PLAN.md
|
|
25
|
+
class AIDecisionEngine
|
|
26
|
+
# Decision templates define prompts, schemas, and defaults for each decision type
|
|
27
|
+
DECISION_TEMPLATES = {
|
|
28
|
+
condition_detection: {
|
|
29
|
+
prompt_template: <<~PROMPT,
|
|
30
|
+
Analyze the following API response or error message and classify the condition.
|
|
31
|
+
|
|
32
|
+
Response/Error:
|
|
33
|
+
{{response}}
|
|
34
|
+
|
|
35
|
+
Classify this into one of the following conditions:
|
|
36
|
+
- rate_limit: API rate limiting or quota exceeded
|
|
37
|
+
- auth_error: Authentication or authorization failure
|
|
38
|
+
- timeout: Request timeout or network timeout
|
|
39
|
+
- completion_marker: Work is complete or done
|
|
40
|
+
- user_feedback_needed: AI is asking for user input/clarification
|
|
41
|
+
- api_error: General API error (not rate limit/auth)
|
|
42
|
+
- success: Successful response
|
|
43
|
+
- other: None of the above
|
|
44
|
+
|
|
45
|
+
Provide your classification with a confidence score (0.0 to 1.0) and brief reasoning.
|
|
46
|
+
PROMPT
|
|
47
|
+
schema: {
|
|
48
|
+
type: "object",
|
|
49
|
+
properties: {
|
|
50
|
+
condition: {
|
|
51
|
+
type: "string",
|
|
52
|
+
enum: [
|
|
53
|
+
"rate_limit",
|
|
54
|
+
"auth_error",
|
|
55
|
+
"timeout",
|
|
56
|
+
"completion_marker",
|
|
57
|
+
"user_feedback_needed",
|
|
58
|
+
"api_error",
|
|
59
|
+
"success",
|
|
60
|
+
"other"
|
|
61
|
+
]
|
|
62
|
+
},
|
|
63
|
+
confidence: {
|
|
64
|
+
type: "number",
|
|
65
|
+
minimum: 0.0,
|
|
66
|
+
maximum: 1.0
|
|
67
|
+
},
|
|
68
|
+
reasoning: {
|
|
69
|
+
type: "string"
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
required: ["condition", "confidence"]
|
|
73
|
+
},
|
|
74
|
+
default_tier: "mini",
|
|
75
|
+
cache_ttl: nil # Each response is unique
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
error_classification: {
|
|
79
|
+
prompt_template: <<~PROMPT,
|
|
80
|
+
Classify the following error and determine if it's retryable.
|
|
81
|
+
|
|
82
|
+
Error:
|
|
83
|
+
{{error_message}}
|
|
84
|
+
|
|
85
|
+
Context:
|
|
86
|
+
{{context}}
|
|
87
|
+
|
|
88
|
+
Determine:
|
|
89
|
+
1. Error type (rate_limit, auth, timeout, network, api_bug, other)
|
|
90
|
+
2. Whether it's retryable (transient vs permanent)
|
|
91
|
+
3. Recommended action (retry, switch_provider, escalate, fail)
|
|
92
|
+
|
|
93
|
+
Provide classification with confidence and reasoning.
|
|
94
|
+
PROMPT
|
|
95
|
+
schema: {
|
|
96
|
+
type: "object",
|
|
97
|
+
properties: {
|
|
98
|
+
error_type: {
|
|
99
|
+
type: "string",
|
|
100
|
+
enum: ["rate_limit", "auth", "timeout", "network", "api_bug", "other"]
|
|
101
|
+
},
|
|
102
|
+
retryable: {
|
|
103
|
+
type: "boolean"
|
|
104
|
+
},
|
|
105
|
+
recommended_action: {
|
|
106
|
+
type: "string",
|
|
107
|
+
enum: ["retry", "switch_provider", "escalate", "fail"]
|
|
108
|
+
},
|
|
109
|
+
confidence: {
|
|
110
|
+
type: "number",
|
|
111
|
+
minimum: 0.0,
|
|
112
|
+
maximum: 1.0
|
|
113
|
+
},
|
|
114
|
+
reasoning: {
|
|
115
|
+
type: "string"
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
required: ["error_type", "retryable", "recommended_action", "confidence"]
|
|
119
|
+
},
|
|
120
|
+
default_tier: "mini",
|
|
121
|
+
cache_ttl: nil
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
completion_detection: {
|
|
125
|
+
prompt_template: <<~PROMPT,
|
|
126
|
+
Determine if the work described is complete based on the AI response.
|
|
127
|
+
|
|
128
|
+
Task:
|
|
129
|
+
{{task_description}}
|
|
130
|
+
|
|
131
|
+
AI Response:
|
|
132
|
+
{{response}}
|
|
133
|
+
|
|
134
|
+
Is the work complete? Consider:
|
|
135
|
+
- Explicit completion markers ("done", "finished", etc.)
|
|
136
|
+
- Implicit indicators (results provided, no follow-up questions)
|
|
137
|
+
- Requests for more information (incomplete)
|
|
138
|
+
|
|
139
|
+
Provide boolean completion status with confidence and reasoning.
|
|
140
|
+
PROMPT
|
|
141
|
+
schema: {
|
|
142
|
+
type: "object",
|
|
143
|
+
properties: {
|
|
144
|
+
complete: {
|
|
145
|
+
type: "boolean"
|
|
146
|
+
},
|
|
147
|
+
confidence: {
|
|
148
|
+
type: "number",
|
|
149
|
+
minimum: 0.0,
|
|
150
|
+
maximum: 1.0
|
|
151
|
+
},
|
|
152
|
+
reasoning: {
|
|
153
|
+
type: "string"
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
required: ["complete", "confidence"]
|
|
157
|
+
},
|
|
158
|
+
default_tier: "mini",
|
|
159
|
+
cache_ttl: nil
|
|
160
|
+
}
|
|
161
|
+
}.freeze
|
|
162
|
+
|
|
163
|
+
attr_reader :config, :provider_factory, :cache
|
|
164
|
+
|
|
165
|
+
# Initialize the AI Decision Engine
|
|
166
|
+
#
|
|
167
|
+
# @param config [Configuration] AIDP configuration object
|
|
168
|
+
# @param provider_factory [ProviderFactory] Factory for creating provider instances
|
|
169
|
+
def initialize(config, provider_factory: nil)
|
|
170
|
+
@config = config
|
|
171
|
+
@provider_factory = provider_factory || ProviderFactory.new(config)
|
|
172
|
+
@cache = {}
|
|
173
|
+
@cache_timestamps = {}
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Make an AI-powered decision
|
|
177
|
+
#
|
|
178
|
+
# @param decision_type [Symbol] Type of decision (:condition_detection, :error_classification, etc.)
|
|
179
|
+
# @param context [Hash] Context data for the decision
|
|
180
|
+
# @param schema [Hash, nil] JSON schema for response validation (overrides default)
|
|
181
|
+
# @param tier [String, nil] Thinking depth tier (overrides default)
|
|
182
|
+
# @param cache_ttl [Integer, nil] Cache TTL in seconds (overrides default)
|
|
183
|
+
# @return [Hash] Validated decision result
|
|
184
|
+
# @raise [ArgumentError] If decision_type is unknown
|
|
185
|
+
# @raise [ValidationError] If response doesn't match schema
|
|
186
|
+
def decide(decision_type, context:, schema: nil, tier: nil, cache_ttl: nil)
|
|
187
|
+
template = DECISION_TEMPLATES[decision_type]
|
|
188
|
+
raise ArgumentError, "Unknown decision type: #{decision_type}" unless template
|
|
189
|
+
|
|
190
|
+
# Check cache if TTL specified
|
|
191
|
+
cache_key = build_cache_key(decision_type, context)
|
|
192
|
+
ttl = cache_ttl || template[:cache_ttl]
|
|
193
|
+
if ttl && (cached_result = get_cached(cache_key, ttl))
|
|
194
|
+
Aidp.log_debug("ai_decision_engine", "Cache hit for #{decision_type}", {
|
|
195
|
+
cache_key: cache_key,
|
|
196
|
+
ttl: ttl
|
|
197
|
+
})
|
|
198
|
+
return cached_result
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Build prompt from template
|
|
202
|
+
prompt = build_prompt(template[:prompt_template], context)
|
|
203
|
+
|
|
204
|
+
# Select tier
|
|
205
|
+
selected_tier = tier || template[:default_tier]
|
|
206
|
+
|
|
207
|
+
# Get model for tier
|
|
208
|
+
thinking_manager = ThinkingDepthManager.new(config)
|
|
209
|
+
provider_name, model_name, _model_data = thinking_manager.select_model_for_tier(selected_tier)
|
|
210
|
+
|
|
211
|
+
Aidp.log_debug("ai_decision_engine", "Making AI decision", {
|
|
212
|
+
decision_type: decision_type,
|
|
213
|
+
tier: selected_tier,
|
|
214
|
+
provider: provider_name,
|
|
215
|
+
model: model_name,
|
|
216
|
+
cache_ttl: ttl
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
# Call AI with schema validation
|
|
220
|
+
response_schema = schema || template[:schema]
|
|
221
|
+
result = call_ai_with_schema(provider_name, model_name, prompt, response_schema)
|
|
222
|
+
|
|
223
|
+
# Validate result
|
|
224
|
+
validate_schema(result, response_schema)
|
|
225
|
+
|
|
226
|
+
# Cache if TTL specified
|
|
227
|
+
if ttl
|
|
228
|
+
set_cached(cache_key, result)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
result
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
private
|
|
235
|
+
|
|
236
|
+
# Build cache key from decision type and context
|
|
237
|
+
def build_cache_key(decision_type, context)
|
|
238
|
+
# Simple hash-based key
|
|
239
|
+
"#{decision_type}:#{context.hash}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Get cached result if still valid
|
|
243
|
+
def get_cached(key, ttl)
|
|
244
|
+
return nil unless @cache.key?(key)
|
|
245
|
+
return nil if Time.now - @cache_timestamps[key] > ttl
|
|
246
|
+
@cache[key]
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Store result in cache
|
|
250
|
+
def set_cached(key, value)
|
|
251
|
+
@cache[key] = value
|
|
252
|
+
@cache_timestamps[key] = Time.now
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Build prompt from template with context substitution
|
|
256
|
+
def build_prompt(template, context)
|
|
257
|
+
prompt = template.dup
|
|
258
|
+
context.each do |key, value|
|
|
259
|
+
prompt.gsub!("{{#{key}}}", value.to_s)
|
|
260
|
+
end
|
|
261
|
+
prompt
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Call AI with schema validation using structured output
|
|
265
|
+
def call_ai_with_schema(provider_name, model_name, prompt, schema)
|
|
266
|
+
# Create provider instance
|
|
267
|
+
provider_options = {
|
|
268
|
+
model: model_name,
|
|
269
|
+
output: nil, # No output for background decisions
|
|
270
|
+
prompt: nil # No TTY prompt needed
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
provider = @provider_factory.create_provider(provider_name, provider_options)
|
|
274
|
+
|
|
275
|
+
# Build enhanced prompt requesting JSON output
|
|
276
|
+
enhanced_prompt = <<~PROMPT
|
|
277
|
+
#{prompt}
|
|
278
|
+
|
|
279
|
+
IMPORTANT: Respond with ONLY valid JSON. No additional text or explanation.
|
|
280
|
+
The JSON must match this structure: #{JSON.generate(schema[:properties].keys)}
|
|
281
|
+
PROMPT
|
|
282
|
+
|
|
283
|
+
# Call provider
|
|
284
|
+
response = provider.send_message(prompt: enhanced_prompt, session: nil)
|
|
285
|
+
|
|
286
|
+
# Parse JSON response
|
|
287
|
+
begin
|
|
288
|
+
# Response might be a string or already structured
|
|
289
|
+
response_text = response.is_a?(String) ? response : response.to_s
|
|
290
|
+
|
|
291
|
+
# Try to extract JSON if there's extra text
|
|
292
|
+
# Use non-greedy match and handle nested braces
|
|
293
|
+
json_match = response_text.match(/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/m) || response_text.match(/\{.*\}/m)
|
|
294
|
+
json_text = json_match ? json_match[0] : response_text
|
|
295
|
+
|
|
296
|
+
result = JSON.parse(json_text, symbolize_names: true)
|
|
297
|
+
|
|
298
|
+
Aidp.log_debug("ai_decision_engine", "Parsed JSON successfully", {
|
|
299
|
+
response_length: response_text.length,
|
|
300
|
+
json_length: json_text.length,
|
|
301
|
+
result_keys: result.keys,
|
|
302
|
+
provider: provider_name
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
result
|
|
306
|
+
rescue JSON::ParserError => e
|
|
307
|
+
Aidp.log_error("ai_decision_engine", "Failed to parse AI response as JSON", {
|
|
308
|
+
error: e.message,
|
|
309
|
+
response: response_text&.slice(0, 200),
|
|
310
|
+
provider: provider_name,
|
|
311
|
+
model: model_name
|
|
312
|
+
})
|
|
313
|
+
raise ValidationError, "AI response is not valid JSON: #{e.message}"
|
|
314
|
+
end
|
|
315
|
+
rescue => e
|
|
316
|
+
Aidp.log_error("ai_decision_engine", "Error calling AI provider", {
|
|
317
|
+
error: e.message,
|
|
318
|
+
provider: provider_name,
|
|
319
|
+
model: model_name,
|
|
320
|
+
error_class: e.class.name
|
|
321
|
+
})
|
|
322
|
+
raise
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Validate response against JSON schema
|
|
326
|
+
def validate_schema(result, schema)
|
|
327
|
+
# Basic validation of required fields and types
|
|
328
|
+
# Schema uses string keys, but our result uses symbol keys from JSON parsing
|
|
329
|
+
schema[:required]&.each do |field|
|
|
330
|
+
field_sym = field.to_sym
|
|
331
|
+
unless result.key?(field_sym)
|
|
332
|
+
raise ValidationError, "Missing required field: #{field}"
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
schema[:properties]&.each do |field, constraints|
|
|
337
|
+
field_sym = field.to_sym
|
|
338
|
+
next unless result.key?(field_sym)
|
|
339
|
+
value = result[field_sym]
|
|
340
|
+
|
|
341
|
+
# Type validation
|
|
342
|
+
case constraints[:type]
|
|
343
|
+
when "string"
|
|
344
|
+
unless value.is_a?(String)
|
|
345
|
+
raise ValidationError, "Field #{field} must be string, got #{value.class}"
|
|
346
|
+
end
|
|
347
|
+
# Enum validation
|
|
348
|
+
if constraints[:enum] && !constraints[:enum].include?(value)
|
|
349
|
+
raise ValidationError, "Field #{field} must be one of #{constraints[:enum]}, got #{value}"
|
|
350
|
+
end
|
|
351
|
+
when "number"
|
|
352
|
+
unless value.is_a?(Numeric)
|
|
353
|
+
raise ValidationError, "Field #{field} must be number, got #{value.class}"
|
|
354
|
+
end
|
|
355
|
+
# Range validation
|
|
356
|
+
if constraints[:minimum] && value < constraints[:minimum]
|
|
357
|
+
raise ValidationError, "Field #{field} must be >= #{constraints[:minimum]}"
|
|
358
|
+
end
|
|
359
|
+
if constraints[:maximum] && value > constraints[:maximum]
|
|
360
|
+
raise ValidationError, "Field #{field} must be <= #{constraints[:maximum]}"
|
|
361
|
+
end
|
|
362
|
+
when "boolean"
|
|
363
|
+
unless [true, false].include?(value)
|
|
364
|
+
raise ValidationError, "Field #{field} must be boolean, got #{value.class}"
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
true
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Validation error for schema violations
|
|
374
|
+
class ValidationError < StandardError; end
|
|
375
|
+
end
|
|
376
|
+
end
|