collavre 0.2.5 → 0.3.1

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/app/channels/collavre/comments_presence_channel.rb +6 -3
  3. data/app/controllers/collavre/admin/orchestration_controller.rb +177 -0
  4. data/app/controllers/collavre/comments_controller.rb +10 -1
  5. data/app/javascript/controllers/comments/presence_controller.js +5 -1
  6. data/app/jobs/collavre/ai_agent_job.rb +55 -4
  7. data/app/jobs/collavre/cron_action_job.rb +53 -0
  8. data/app/jobs/collavre/stuck_detector_job.rb +34 -0
  9. data/app/models/collavre/comment.rb +15 -15
  10. data/app/models/collavre/creative.rb +1 -1
  11. data/app/models/collavre/orchestrator_policy.rb +124 -0
  12. data/app/models/collavre/task.rb +3 -0
  13. data/app/models/collavre/user.rb +1 -1
  14. data/app/services/collavre/ai_agent_service.rb +32 -1
  15. data/app/services/collavre/ai_client.rb +4 -3
  16. data/app/services/collavre/comments/command_processor.rb +4 -1
  17. data/app/services/collavre/comments/topic_command.rb +101 -0
  18. data/app/services/collavre/orchestration/agent_context_builder.rb +201 -0
  19. data/app/services/collavre/orchestration/agent_orchestrator.rb +142 -0
  20. data/app/services/collavre/orchestration/arbiter.rb +257 -0
  21. data/app/services/collavre/orchestration/loop_breaker.rb +294 -0
  22. data/app/services/collavre/orchestration/matcher.rb +98 -0
  23. data/app/services/collavre/orchestration/policy_resolver.rb +170 -0
  24. data/app/services/collavre/orchestration/resource_tracker.rb +135 -0
  25. data/app/services/collavre/orchestration/scheduler.rb +145 -0
  26. data/app/services/collavre/orchestration/self_reflection_evaluator.rb +231 -0
  27. data/app/services/collavre/orchestration/stuck_detector.rb +303 -0
  28. data/app/services/collavre/system_events/context_builder.rb +34 -7
  29. data/app/services/collavre/system_events/dispatcher.rb +2 -7
  30. data/app/services/collavre/tools/cron_cancel_service.rb +49 -0
  31. data/app/services/collavre/tools/cron_create_service.rb +73 -0
  32. data/app/services/collavre/tools/cron_list_service.rb +65 -0
  33. data/app/services/collavre/tools/cron_update_service.rb +82 -0
  34. data/app/views/admin/shared/_tabs.html.erb +1 -0
  35. data/app/views/collavre/admin/orchestration/show.html.erb +66 -0
  36. data/app/views/collavre/creatives/index.html.erb +1 -1
  37. data/config/locales/ai_agent.en.yml +21 -0
  38. data/config/locales/ai_agent.ko.yml +21 -0
  39. data/config/locales/comments.en.yml +8 -0
  40. data/config/locales/comments.ko.yml +8 -0
  41. data/config/routes.rb +5 -1
  42. data/db/migrate/20260206005035_create_orchestrator_policies.rb +32 -0
  43. data/db/migrate/20260206094509_add_retry_count_to_tasks.rb +5 -0
  44. data/db/migrate/20260206100000_add_topic_id_to_tasks.rb +6 -0
  45. data/lib/collavre/version.rb +1 -1
  46. metadata +24 -13
  47. data/app/controllers/collavre/github_auth_controller.rb +0 -25
  48. data/app/models/collavre/github_account.rb +0 -10
  49. data/app/models/collavre/github_repository_link.rb +0 -19
  50. data/app/services/collavre/github/client.rb +0 -112
  51. data/app/services/collavre/github/pull_request_analyzer.rb +0 -280
  52. data/app/services/collavre/github/pull_request_processor.rb +0 -181
  53. data/app/services/collavre/github/webhook_provisioner.rb +0 -130
  54. data/app/services/collavre/system_events/router.rb +0 -96
  55. data/app/views/collavre/creatives/_github_integration_modal.html.erb +0 -77
  56. data/db/migrate/20250925000000_create_github_integrations.rb +0 -26
  57. data/db/migrate/20250927000000_add_webhook_secret_to_github_repository_links.rb +0 -29
  58. data/db/migrate/20250928105957_add_github_gemini_prompt_to_creatives.rb +0 -5
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Orchestration
5
+ # PolicyResolver finds and merges applicable policies for a given context.
6
+ #
7
+ # Policy precedence (later overrides earlier):
8
+ # 1. Global policies
9
+ # 2. Creative-level policies
10
+ # 3. Topic-level policies
11
+ # 4. Agent-level policies (for scheduling)
12
+ #
13
+ # Usage:
14
+ # resolver = PolicyResolver.new(context)
15
+ # resolver.arbitration_config
16
+ # # => { "strategy" => "primary_first", "max_responders" => 1 }
17
+ #
18
+ class PolicyResolver
19
+ # Default configurations when no policy is set
20
+ DEFAULTS = {
21
+ "arbitration" => {
22
+ "strategy" => "all",
23
+ "max_responders" => nil # nil means unlimited
24
+ },
25
+ "scheduling" => {
26
+ "max_concurrent_jobs" => 5,
27
+ "daily_token_limit" => 100_000,
28
+ "rate_limit_per_minute" => 20,
29
+ "backoff_strategy" => "exponential",
30
+ "topic_max_concurrent_jobs" => 1,
31
+ # Self-reflection settings
32
+ "self_reflection_enabled" => false,
33
+ "confidence_threshold" => 70,
34
+ "max_retries" => 3,
35
+ "retry_delay_seconds" => 5,
36
+ # Loop breaker settings
37
+ "loop_breaker_enabled" => false,
38
+ "ping_pong_threshold" => 5, # Max back-and-forth between same agents
39
+ "creative_retry_threshold" => 10, # Max tasks on same creative in time window
40
+ "creative_retry_window_minutes" => 30,
41
+ "task_timeout_minutes" => 60, # Max time for a single task
42
+ "token_spike_threshold" => 50_000, # Token usage spike in window
43
+ "token_spike_window_minutes" => 10
44
+ },
45
+ "stuck_detection" => {
46
+ "enabled" => true,
47
+ "task_stuck_threshold_minutes" => 30, # Task running for > N minutes
48
+ "creative_stall_threshold_minutes" => 120, # Creative no progress for > N minutes
49
+ "create_system_comment" => true # Create system comment on escalation
50
+ }
51
+ }.freeze
52
+
53
+ def initialize(context)
54
+ @context = context
55
+ end
56
+
57
+ # Get merged arbitration config
58
+ def arbitration_config
59
+ @arbitration_config ||= merge_policies("arbitration")
60
+ end
61
+
62
+ # Get merged scheduling config for a specific agent
63
+ def scheduling_config_for(agent)
64
+ base = merge_policies("scheduling")
65
+
66
+ # Layer agent-specific policies on top
67
+ agent_policies = OrchestratorPolicy.for_agent(agent.id, policy_type: "scheduling")
68
+ agent_policies.each do |policy|
69
+ base = base.merge(policy.config)
70
+ end
71
+
72
+ base
73
+ end
74
+
75
+ # Get merged scheduling config (without agent-specific overrides)
76
+ def scheduling_config
77
+ @scheduling_config ||= merge_policies("scheduling")
78
+ end
79
+
80
+ # Topic-level concurrency limit
81
+ def topic_max_concurrent_jobs
82
+ scheduling_config["topic_max_concurrent_jobs"]
83
+ end
84
+
85
+ # Convenience methods
86
+ def arbitration_strategy
87
+ arbitration_config["strategy"]
88
+ end
89
+
90
+ def max_responders
91
+ arbitration_config["max_responders"]
92
+ end
93
+
94
+ def primary_agent_id
95
+ arbitration_config["primary_agent_id"]
96
+ end
97
+
98
+ # Bid strategy specific
99
+ def confidence_threshold
100
+ arbitration_config["confidence_threshold"]
101
+ end
102
+
103
+ def bid_fallback_enabled?
104
+ arbitration_config["bid_fallback_enabled"] != false
105
+ end
106
+
107
+ # Self-reflection settings
108
+ def self_reflection_enabled?
109
+ scheduling_config["self_reflection_enabled"] == true
110
+ end
111
+
112
+ def self_reflection_config
113
+ {
114
+ "enabled" => scheduling_config["self_reflection_enabled"],
115
+ "confidence_threshold" => scheduling_config["confidence_threshold"],
116
+ "max_retries" => scheduling_config["max_retries"],
117
+ "retry_delay_seconds" => scheduling_config["retry_delay_seconds"]
118
+ }
119
+ end
120
+
121
+ # Loop breaker settings
122
+ def loop_breaker_enabled?
123
+ scheduling_config["loop_breaker_enabled"] == true
124
+ end
125
+
126
+ def loop_breaker_config
127
+ {
128
+ "enabled" => scheduling_config["loop_breaker_enabled"],
129
+ "ping_pong_threshold" => scheduling_config["ping_pong_threshold"],
130
+ "creative_retry_threshold" => scheduling_config["creative_retry_threshold"],
131
+ "creative_retry_window_minutes" => scheduling_config["creative_retry_window_minutes"],
132
+ "task_timeout_minutes" => scheduling_config["task_timeout_minutes"],
133
+ "token_spike_threshold" => scheduling_config["token_spike_threshold"],
134
+ "token_spike_window_minutes" => scheduling_config["token_spike_window_minutes"]
135
+ }
136
+ end
137
+
138
+ # Stuck detection settings
139
+ def stuck_detection_enabled?
140
+ stuck_detection_config["enabled"] == true
141
+ end
142
+
143
+ def stuck_detection_config
144
+ @stuck_detection_config ||= resolve("stuck_detection")
145
+ end
146
+
147
+ # Generic resolve method for any policy type
148
+ def resolve(policy_type)
149
+ merge_policies(policy_type)
150
+ end
151
+
152
+ private
153
+
154
+ def merge_policies(policy_type)
155
+ # Start with defaults
156
+ config = DEFAULTS[policy_type]&.dup || {}
157
+
158
+ # Get applicable policies in priority order
159
+ policies = OrchestratorPolicy.for_context(@context, policy_type: policy_type)
160
+
161
+ # Merge each policy's config (later policies override earlier)
162
+ policies.each do |policy|
163
+ config = config.merge(policy.config)
164
+ end
165
+
166
+ config
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Orchestration
5
+ # ResourceTracker tracks per-agent resource usage for scheduling decisions.
6
+ #
7
+ # Tracks:
8
+ # - Active jobs count (concurrency)
9
+ # - Daily token usage (quota)
10
+ # - Request rate (rate limiting)
11
+ #
12
+ # Uses Rails.cache by default (memory-based).
13
+ # Can be extended to use Redis for multi-server deployments.
14
+ #
15
+ # Usage:
16
+ # tracker = ResourceTracker.for(agent)
17
+ # tracker.active_jobs # => 2
18
+ # tracker.can_accept_work? # => true/false
19
+ # tracker.reserve!(job_id)
20
+ # tracker.release!(job_id, tokens_used: 1500)
21
+ #
22
+ class ResourceTracker
23
+ CACHE_EXPIRY_ACTIVE_JOBS = 1.hour
24
+ CACHE_EXPIRY_TOKENS = 2.days
25
+ CACHE_EXPIRY_RATE = 2.minutes
26
+
27
+ class << self
28
+ def for(agent)
29
+ new(agent)
30
+ end
31
+ end
32
+
33
+ def initialize(agent)
34
+ @agent = agent
35
+ @key_prefix = "orchestrator:agent:#{agent.id}"
36
+ end
37
+
38
+ # Current number of active jobs for this agent
39
+ def active_jobs
40
+ active_job_set.size
41
+ end
42
+
43
+ # Total tokens used today
44
+ def tokens_today
45
+ cache_read(tokens_today_key) || 0
46
+ end
47
+
48
+ # Requests in the current minute window
49
+ def requests_this_minute
50
+ cache_read(rate_limit_key) || 0
51
+ end
52
+
53
+ # Check if rate limited (exceeds requests per minute)
54
+ def rate_limited?(limit)
55
+ requests_this_minute >= limit
56
+ end
57
+
58
+ # Reserve resources for a job (call before starting work)
59
+ def reserve!(job_id)
60
+ jobs = active_job_set
61
+ jobs.add(job_id.to_s)
62
+ cache_write(active_jobs_key, jobs, expires_in: CACHE_EXPIRY_ACTIVE_JOBS)
63
+
64
+ # Increment rate counter
65
+ increment_rate_counter
66
+
67
+ true
68
+ end
69
+
70
+ # Release resources after job completion
71
+ def release!(job_id, tokens_used: 0)
72
+ # Remove from active jobs
73
+ jobs = active_job_set
74
+ jobs.delete(job_id.to_s)
75
+ cache_write(active_jobs_key, jobs, expires_in: CACHE_EXPIRY_ACTIVE_JOBS)
76
+
77
+ # Add to daily token usage
78
+ if tokens_used.positive?
79
+ current_tokens = tokens_today
80
+ cache_write(tokens_today_key, current_tokens + tokens_used, expires_in: CACHE_EXPIRY_TOKENS)
81
+ end
82
+
83
+ true
84
+ end
85
+
86
+ # Get current resource status
87
+ def status
88
+ {
89
+ active_jobs: active_jobs,
90
+ tokens_today: tokens_today,
91
+ requests_this_minute: requests_this_minute
92
+ }
93
+ end
94
+
95
+ # Reset all tracking (useful for testing)
96
+ def reset!
97
+ Rails.cache.delete(active_jobs_key)
98
+ Rails.cache.delete(tokens_today_key)
99
+ Rails.cache.delete(rate_limit_key)
100
+ end
101
+
102
+ private
103
+
104
+ def active_job_set
105
+ cache_read(active_jobs_key) || Set.new
106
+ end
107
+
108
+ def increment_rate_counter
109
+ current = requests_this_minute
110
+ cache_write(rate_limit_key, current + 1, expires_in: CACHE_EXPIRY_RATE)
111
+ end
112
+
113
+ def active_jobs_key
114
+ "#{@key_prefix}:active_jobs"
115
+ end
116
+
117
+ def tokens_today_key
118
+ "#{@key_prefix}:tokens:#{Date.current}"
119
+ end
120
+
121
+ def rate_limit_key
122
+ # Window by minute
123
+ "#{@key_prefix}:rate:#{Time.current.to_i / 60}"
124
+ end
125
+
126
+ def cache_read(key)
127
+ Rails.cache.read(key)
128
+ end
129
+
130
+ def cache_write(key, value, expires_in:)
131
+ Rails.cache.write(key, value, expires_in: expires_in)
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Orchestration
5
+ # Scheduler decides when to execute agent jobs based on resource availability.
6
+ #
7
+ # Scheduling decisions:
8
+ # - :immediate - Execute now
9
+ # - :delayed - Execute after a delay (with :delay value)
10
+ # - :rejected - Do not execute (with :reason)
11
+ #
12
+ # Resource checks:
13
+ # - Active jobs count (concurrency limit)
14
+ # - Daily token usage (quota)
15
+ # - Rate limiting (requests per minute)
16
+ #
17
+ class Scheduler
18
+ # Default backoff delays
19
+ BACKOFF_DELAYS = {
20
+ immediate: 0,
21
+ linear: 30.seconds,
22
+ exponential_base: 10.seconds
23
+ }.freeze
24
+
25
+ def initialize(context, policy_resolver: nil)
26
+ @context = context
27
+ @policy_resolver = policy_resolver || PolicyResolver.new(context)
28
+ end
29
+
30
+ # Schedule agents for execution
31
+ # @param agents [Array<User>] Agents selected by Arbiter
32
+ # @return [Array<Hash>] Scheduling decisions
33
+ # Each decision: { agent:, timing:, delay: (optional), reason: (optional) }
34
+ def schedule(agents)
35
+ topic_immediate_count = 0
36
+ agents.map do |agent|
37
+ decision = evaluate(agent, topic_immediate_count: topic_immediate_count)
38
+ topic_immediate_count += 1 if decision[:timing] == :immediate
39
+ decision
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def evaluate(agent, topic_immediate_count: 0)
46
+ tracker = ResourceTracker.for(agent)
47
+ config = @policy_resolver.scheduling_config_for(agent)
48
+
49
+ # Check 0: Loop breaker
50
+ if @policy_resolver.loop_breaker_enabled?
51
+ loop_result = LoopBreaker.new(@context, policy_resolver: @policy_resolver).check
52
+ if loop_result.should_break?
53
+ return loop_broken_decision(agent, loop_result)
54
+ end
55
+ end
56
+
57
+ # Check 1: Concurrency limit
58
+ max_concurrent = config["max_concurrent_jobs"] || 5
59
+ if tracker.active_jobs >= max_concurrent
60
+ return delayed_decision(agent, :busy, config)
61
+ end
62
+
63
+ # Check 2: Daily token quota
64
+ daily_limit = config["daily_token_limit"]
65
+ if daily_limit && tracker.tokens_today >= daily_limit
66
+ return rejected_decision(agent, :quota_exceeded)
67
+ end
68
+
69
+ # Check 3: Rate limit
70
+ rate_limit = config["rate_limit_per_minute"]
71
+ if rate_limit && tracker.rate_limited?(rate_limit)
72
+ return delayed_decision(agent, :rate_limited, config)
73
+ end
74
+
75
+ # Check 4: Topic concurrency limit (applies to Main topic with nil topic_id too)
76
+ topic_max = @policy_resolver.topic_max_concurrent_jobs
77
+ if topic_max && @context.key?("topic")
78
+ topic_id = @context.dig("topic", "id")
79
+ if (Task.running_for_topic(topic_id).count + topic_immediate_count) >= topic_max
80
+ return deferred_decision(agent)
81
+ end
82
+ end
83
+
84
+ # All checks passed - immediate execution
85
+ immediate_decision(agent)
86
+ end
87
+
88
+ def immediate_decision(agent)
89
+ {
90
+ agent: agent,
91
+ timing: :immediate
92
+ }
93
+ end
94
+
95
+ def delayed_decision(agent, reason, config)
96
+ {
97
+ agent: agent,
98
+ timing: :delayed,
99
+ delay: calculate_delay(config, reason),
100
+ reason: reason
101
+ }
102
+ end
103
+
104
+ def deferred_decision(agent)
105
+ {
106
+ agent: agent,
107
+ timing: :deferred
108
+ }
109
+ end
110
+
111
+ def rejected_decision(agent, reason)
112
+ {
113
+ agent: agent,
114
+ timing: :rejected,
115
+ reason: reason
116
+ }
117
+ end
118
+
119
+ def loop_broken_decision(agent, loop_result)
120
+ {
121
+ agent: agent,
122
+ timing: :rejected,
123
+ reason: :loop_detected,
124
+ loop_break_reason: loop_result.reason,
125
+ loop_break_details: loop_result.details
126
+ }
127
+ end
128
+
129
+ def calculate_delay(config, _reason)
130
+ strategy = config["backoff_strategy"] || "linear"
131
+
132
+ case strategy
133
+ when "immediate"
134
+ BACKOFF_DELAYS[:immediate]
135
+ when "exponential"
136
+ # Simple exponential: base * 2^(active_jobs - max)
137
+ # For now, use a fixed multiplier
138
+ BACKOFF_DELAYS[:exponential_base] * 2
139
+ else # linear
140
+ BACKOFF_DELAYS[:linear]
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Orchestration
5
+ # SelfReflectionEvaluator evaluates an agent's response and determines
6
+ # whether the agent should retry, escalate, or mark the task as done.
7
+ #
8
+ # It parses confidence signals from the response and compares against
9
+ # policy-configured thresholds.
10
+ #
11
+ # Usage:
12
+ # evaluator = SelfReflectionEvaluator.new(task)
13
+ # result = evaluator.evaluate
14
+ # case result.action
15
+ # when :done then task.update!(status: "done")
16
+ # when :retry then schedule_retry(result.delay)
17
+ # when :escalate then notify_admin(result.reason)
18
+ # end
19
+ #
20
+ class SelfReflectionEvaluator
21
+ Result = Struct.new(:action, :delay, :reason, :confidence, keyword_init: true)
22
+
23
+ # Patterns to extract confidence from agent response
24
+ CONFIDENCE_PATTERNS = [
25
+ /확신도[:\s]*(\d+)/i,
26
+ /confidence[:\s]*(\d+)/i,
27
+ /\[confidence[:\s]*(\d+)\]/i,
28
+ /확신[:\s]*(\d+)%?/i
29
+ ].freeze
30
+
31
+ # Patterns that indicate the agent is stuck or uncertain
32
+ UNCERTAINTY_PATTERNS = [
33
+ /잘\s*모르겠/,
34
+ /확실하지\s*않/,
35
+ /불확실/,
36
+ /도움이?\s*필요/,
37
+ /막혔/,
38
+ /I('m| am)\s*(not sure|uncertain|stuck)/i,
39
+ /need(s)?\s*help/i,
40
+ /cannot\s*(proceed|continue)/i
41
+ ].freeze
42
+
43
+ def initialize(task, response_content: nil, policy_resolver: nil)
44
+ @task = task
45
+ @response_content = response_content || ""
46
+ @policy_resolver = policy_resolver ||
47
+ PolicyResolver.new(task.trigger_event_payload || {})
48
+ end
49
+
50
+ # Evaluate the task's response and return a Result
51
+ # @return [Result] with :action (:done, :retry, :escalate), :delay, :reason, :confidence
52
+ def evaluate
53
+ return Result.new(action: :done, reason: "self_reflection_disabled") unless enabled?
54
+
55
+ confidence = parse_confidence
56
+ uncertainty_detected = detect_uncertainty
57
+
58
+ if confidence.nil? && !uncertainty_detected
59
+ # No confidence signal and no uncertainty - assume done
60
+ Result.new(action: :done, confidence: nil, reason: "no_signal")
61
+ elsif confidence && confidence >= threshold
62
+ # Confidence meets threshold - done
63
+ Result.new(action: :done, confidence: confidence, reason: "threshold_met")
64
+ elsif @task.retry_count >= max_retries
65
+ # Max retries exceeded - escalate
66
+ Result.new(
67
+ action: :escalate,
68
+ confidence: confidence,
69
+ reason: "max_retries_exceeded"
70
+ )
71
+ else
72
+ # Below threshold or uncertainty detected - retry
73
+ Result.new(
74
+ action: :retry,
75
+ delay: retry_delay,
76
+ confidence: confidence,
77
+ reason: uncertainty_detected ? "uncertainty_detected" : "below_threshold"
78
+ )
79
+ end
80
+ end
81
+
82
+ # Schedule a retry by creating a system message and re-triggering
83
+ # @param result [Result] pre-computed result from evaluate
84
+ def schedule_retry!(result)
85
+ return result unless result.action == :retry
86
+
87
+ # Increment retry count
88
+ @task.increment!(:retry_count)
89
+
90
+ # Create self-reflection prompt as a new comment
91
+ create_reflection_prompt
92
+
93
+ # Schedule the retry job
94
+ if result.delay.positive?
95
+ AiAgentJob.set(wait: result.delay.seconds).perform_later(@task)
96
+ else
97
+ AiAgentJob.perform_later(@task)
98
+ end
99
+
100
+ result
101
+ end
102
+
103
+ # Escalate to admin users
104
+ # @param result [Result] pre-computed result from evaluate
105
+ def escalate!(result)
106
+ return result unless result.action == :escalate
107
+
108
+ @task.update!(status: "escalated")
109
+ create_escalation_notice
110
+ notify_admins
111
+
112
+ result
113
+ end
114
+
115
+ private
116
+
117
+ def config
118
+ @config ||= @policy_resolver.self_reflection_config
119
+ end
120
+
121
+ def enabled?
122
+ config["enabled"] == true
123
+ end
124
+
125
+ def threshold
126
+ config["confidence_threshold"] || 70
127
+ end
128
+
129
+ def max_retries
130
+ config["max_retries"] || 3
131
+ end
132
+
133
+ def retry_delay
134
+ config["retry_delay_seconds"] || 5
135
+ end
136
+
137
+ def response_content
138
+ @response_content
139
+ end
140
+
141
+ def parse_confidence
142
+ CONFIDENCE_PATTERNS.each do |pattern|
143
+ match = response_content.match(pattern)
144
+ return match[1].to_i if match
145
+ end
146
+ nil
147
+ end
148
+
149
+ def detect_uncertainty
150
+ UNCERTAINTY_PATTERNS.any? { |pattern| response_content.match?(pattern) }
151
+ end
152
+
153
+ def create_reflection_prompt
154
+ creative_id = @task.trigger_event_payload&.dig("creative", "id")
155
+ return unless creative_id && @task.topic_id
156
+
157
+ creative = Creative.find_by(id: creative_id)
158
+ return unless creative
159
+
160
+ # Create a system message prompting self-reflection
161
+ creative.comments.create!(
162
+ content: reflection_prompt_content,
163
+ user: system_user,
164
+ topic_id: @task.topic_id,
165
+ private: false
166
+ )
167
+ end
168
+
169
+ def reflection_prompt_content
170
+ <<~PROMPT.strip
171
+ [시스템] 확신도가 낮습니다 (#{@task.retry_count + 1}/#{max_retries} 재시도).
172
+
173
+ 다시 검토해주세요:
174
+ - 다른 접근법이 있나요?
175
+ - 부족한 정보가 있다면 @멘션으로 다른 Agent에게 요청하세요.
176
+ - 그래도 해결이 어려우면 상위로 에스컬레이션하세요.
177
+
178
+ 응답 시 확신도를 명시해주세요: [confidence: 0-100]
179
+ PROMPT
180
+ end
181
+
182
+ def create_escalation_notice
183
+ creative_id = @task.trigger_event_payload&.dig("creative", "id")
184
+ return unless creative_id && @task.topic_id
185
+
186
+ creative = Creative.find_by(id: creative_id)
187
+ return unless creative
188
+
189
+ creative.comments.create!(
190
+ content: escalation_notice_content,
191
+ user: system_user,
192
+ topic_id: @task.topic_id,
193
+ private: false
194
+ )
195
+ end
196
+
197
+ def escalation_notice_content
198
+ <<~NOTICE.strip
199
+ [시스템] ⚠️ 에스컬레이션 발생
200
+
201
+ Agent: #{@task.agent&.name}
202
+ 작업: #{@task.name}
203
+ 재시도 횟수: #{@task.retry_count}회
204
+
205
+ 최대 재시도 횟수를 초과하여 관리자에게 에스컬레이션되었습니다.
206
+ admin 권한을 가진 사용자의 검토가 필요합니다.
207
+ NOTICE
208
+ end
209
+
210
+ def notify_admins
211
+ # Find admin users for this creative and notify them
212
+ creative_id = @task.trigger_event_payload&.dig("creative", "id")
213
+ return unless creative_id
214
+
215
+ creative = Creative.find_by(id: creative_id)
216
+ return unless creative
217
+
218
+ # This could trigger notifications via various channels
219
+ # For now, the escalation notice in the topic serves as notification
220
+ Rails.logger.info(
221
+ "[SelfReflection] Task #{@task.id} escalated for creative #{creative_id}"
222
+ )
223
+ end
224
+
225
+ def system_user
226
+ @system_user ||= User.find_by(email: "system@collavre.local") ||
227
+ @task.agent # Fallback to agent if no system user
228
+ end
229
+ end
230
+ end
231
+ end