collavre 0.5.0 → 0.7.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/app/assets/stylesheets/collavre/comment_versions.css +76 -0
- data/app/assets/stylesheets/collavre/comments_popup.css +347 -37
- data/app/assets/stylesheets/collavre/creatives.css +73 -1
- data/app/assets/stylesheets/collavre/org_chart.css +319 -0
- data/app/assets/stylesheets/collavre/popup.css +68 -1
- data/app/controllers/collavre/application_controller.rb +13 -0
- data/app/controllers/collavre/comments/versions_controller.rb +82 -0
- data/app/controllers/collavre/comments_controller.rb +14 -153
- data/app/controllers/collavre/concerns/exportable.rb +30 -0
- data/app/controllers/collavre/concerns/shareable.rb +28 -0
- data/app/controllers/collavre/concerns/slide_viewable.rb +37 -0
- data/app/controllers/collavre/concerns/tree_manageable.rb +141 -0
- data/app/controllers/collavre/creative_imports_controller.rb +6 -0
- data/app/controllers/collavre/creative_invitations_controller.rb +46 -0
- data/app/controllers/collavre/creative_plans_controller.rb +1 -1
- data/app/controllers/collavre/creative_shares_controller.rb +84 -14
- data/app/controllers/collavre/creatives_controller.rb +70 -194
- data/app/controllers/collavre/google_auth_controller.rb +3 -0
- data/app/controllers/collavre/invites_controller.rb +2 -1
- data/app/controllers/collavre/sessions_controller.rb +3 -0
- data/app/controllers/collavre/topics_controller.rb +39 -2
- data/app/controllers/collavre/users_controller.rb +5 -404
- data/app/controllers/concerns/collavre/comments/approval_actions.rb +108 -0
- data/app/controllers/concerns/collavre/comments/batch_operations.rb +55 -0
- data/app/controllers/concerns/collavre/comments/conversion.rb +46 -0
- data/app/controllers/concerns/collavre/users_controller/admin_operations.rb +74 -0
- data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +119 -0
- data/app/controllers/concerns/collavre/users_controller/contact_management.rb +166 -0
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +102 -0
- data/app/controllers/concerns/collavre/users_controller/registration.rb +63 -0
- data/app/helpers/collavre/application_helper.rb +1 -0
- data/app/helpers/collavre/creatives_helper.rb +12 -9
- data/app/helpers/collavre/navigation_helper.rb +1 -1
- data/app/javascript/collavre.js +0 -1
- data/app/javascript/controllers/comment_controller.js +33 -70
- data/app/javascript/controllers/comment_version_controller.js +164 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +305 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_selection.test.js +103 -0
- data/app/javascript/controllers/comments/__tests__/review_quotes_store.test.js +113 -0
- data/app/javascript/controllers/comments/contexts_controller.js +363 -0
- data/app/javascript/controllers/comments/form_controller.js +304 -13
- data/app/javascript/controllers/comments/list_controller.js +151 -62
- data/app/javascript/controllers/comments/popup_controller.js +66 -38
- data/app/javascript/controllers/comments/presence_controller.js +2 -10
- data/app/javascript/controllers/comments/review_quotes_store.js +189 -0
- data/app/javascript/controllers/comments/topics_controller.js +34 -10
- data/app/javascript/controllers/index.js +15 -1
- data/app/javascript/controllers/org_chart_controller.js +46 -0
- data/app/javascript/controllers/share_modal_controller.js +369 -0
- data/app/javascript/controllers/topic_search_controller.js +103 -0
- data/app/javascript/creatives/drag_drop/event_handlers.js +42 -1
- data/app/javascript/lib/api/creatives.js +12 -0
- data/app/javascript/lib/api/csrf_fetch.js +35 -0
- data/app/javascript/lib/api/drag_drop.js +17 -0
- data/app/javascript/modules/command_menu.js +40 -0
- data/app/javascript/modules/creative_row_editor.js +88 -0
- data/app/javascript/modules/slide_view.js +2 -1
- data/app/jobs/collavre/ai_agent_job.rb +42 -30
- data/app/jobs/collavre/compress_job.rb +92 -0
- data/app/models/collavre/comment.rb +36 -1
- data/app/models/collavre/comment_version.rb +15 -0
- data/app/models/collavre/creative/describable.rb +1 -1
- data/app/models/collavre/creative.rb +51 -0
- data/app/models/collavre/task.rb +30 -2
- data/app/models/collavre/user.rb +20 -3
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +68 -0
- data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +89 -0
- data/app/services/collavre/ai_agent/message_builder.rb +85 -6
- data/app/services/collavre/ai_agent/response_finalizer.rb +97 -0
- data/app/services/collavre/ai_agent/response_streamer.rb +56 -0
- data/app/services/collavre/ai_agent/review_handler.rb +18 -1
- data/app/services/collavre/ai_agent_service.rb +130 -183
- data/app/services/collavre/ai_client.rb +6 -0
- data/app/services/collavre/auto_theme_generator.rb +1 -1
- data/app/services/collavre/command_menu_service.rb +19 -0
- data/app/services/collavre/comments/command_processor.rb +3 -1
- data/app/services/collavre/comments/compress_command.rb +75 -0
- data/app/services/collavre/comments/concerns/workflow_support.rb +115 -0
- data/app/services/collavre/comments/work_command.rb +161 -0
- data/app/services/collavre/comments/workflow_executor.rb +276 -0
- data/app/services/collavre/creatives/plan_tagger.rb +14 -3
- data/app/services/collavre/creatives/tree_formatter.rb +53 -13
- data/app/services/collavre/gemini_parent_recommender.rb +4 -4
- data/app/services/collavre/orchestration/agent_context_builder.rb +1 -3
- data/app/services/collavre/orchestration/agent_orchestrator.rb +15 -4
- data/app/services/collavre/orchestration/policy_resolver.rb +0 -19
- data/app/services/collavre/orchestration/scheduler.rb +3 -2
- data/app/services/collavre/orchestration/stuck_detector.rb +1 -1
- data/app/services/collavre/system_events/dispatcher.rb +9 -0
- data/app/services/collavre/tools/creative_create_service.rb +1 -8
- data/app/services/collavre/tools/creative_import_service.rb +46 -0
- data/app/services/collavre/tools/creative_retrieval_service.rb +157 -96
- data/app/services/collavre/tools/creative_update_service.rb +1 -8
- data/app/services/collavre/tools/cron_list_service.rb +1 -1
- data/app/services/collavre/tools/description_normalizable.rb +16 -0
- data/app/views/collavre/comments/_comment.html.erb +25 -8
- data/app/views/collavre/comments/_comments_popup.html.erb +32 -5
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +13 -0
- data/app/views/collavre/creatives/_share_button.html.erb +4 -1
- data/app/views/collavre/creatives/_share_modal.html.erb +31 -1
- data/app/views/collavre/creatives/index.html.erb +5 -5
- data/app/views/collavre/creatives/slide_view.html.erb +1 -1
- data/app/views/collavre/users/{_contact_management.html.erb → _contact_list.html.erb} +4 -8
- data/app/views/collavre/users/_org_chart.html.erb +68 -0
- data/app/views/collavre/users/_org_chart_node.html.erb +169 -0
- data/app/views/collavre/users/new_ai.html.erb +9 -0
- data/app/views/collavre/users/show.html.erb +32 -8
- data/config/locales/comments.en.yml +57 -2
- data/config/locales/comments.ko.yml +57 -2
- data/config/locales/contacts.en.yml +31 -0
- data/config/locales/contacts.ko.yml +31 -0
- data/config/locales/contexts.en.yml +8 -0
- data/config/locales/contexts.ko.yml +8 -0
- data/config/locales/creatives.en.yml +6 -0
- data/config/locales/creatives.ko.yml +6 -0
- data/config/locales/users.en.yml +1 -0
- data/config/locales/users.ko.yml +1 -0
- data/config/routes.rb +14 -1
- data/db/migrate/20260220072200_add_workflow_fields_to_tasks.rb +12 -0
- data/db/migrate/20260223173533_add_review_type_to_comments.rb +5 -0
- data/db/migrate/20260225065200_create_comment_versions.rb +14 -0
- data/db/migrate/20260225074416_add_selected_version_id_to_comments.rb +7 -0
- data/lib/collavre/version.rb +1 -1
- metadata +47 -10
- data/app/javascript/lib/lexical/__tests__/action_text_attachment_node.test.jsx +0 -91
- data/app/javascript/lib/lexical/action_text_attachment_node.js +0 -459
- data/app/javascript/lib/lexical/dom_attachment_utils.js +0 -66
- data/app/javascript/modules/share_modal.js +0 -76
- data/app/javascript/modules/share_user_popup.js +0 -77
- data/app/services/collavre/orchestration/self_reflection_evaluator.rb +0 -231
- data/app/views/collavre/comments/_presence_avatars.html.erb +0 -8
- data/app/views/collavre/creatives/_delete_button.html.erb +0 -12
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
module Comments
|
|
3
|
+
class WorkCommand
|
|
4
|
+
include Concerns::WorkflowSupport
|
|
5
|
+
|
|
6
|
+
def initialize(comment:, user:)
|
|
7
|
+
@comment = comment
|
|
8
|
+
@user = user
|
|
9
|
+
@creative = comment.creative.effective_origin
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
return unless work_command?
|
|
14
|
+
execute_work
|
|
15
|
+
rescue StandardError => e
|
|
16
|
+
Rails.logger.error("Work command failed: #{e.message}")
|
|
17
|
+
e.message
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
COMMAND_PATTERN = /\A\/work\b/i.freeze
|
|
23
|
+
|
|
24
|
+
attr_reader :comment, :user, :creative
|
|
25
|
+
|
|
26
|
+
def work_command?
|
|
27
|
+
comment.content.to_s.strip.match?(COMMAND_PATTERN)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def execute_work
|
|
31
|
+
subcommand = parse_subcommand
|
|
32
|
+
case subcommand
|
|
33
|
+
when :stop then execute_stop
|
|
34
|
+
when :resume then execute_resume
|
|
35
|
+
else execute_start
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def parse_subcommand
|
|
40
|
+
content = comment.content.to_s.strip.sub(/\A\/work\s*/i, "")
|
|
41
|
+
case content
|
|
42
|
+
when /\Astop\b/i then :stop
|
|
43
|
+
when /\Aresume\b/i then :resume
|
|
44
|
+
else :start
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# --- /work stop ---
|
|
49
|
+
|
|
50
|
+
def execute_stop
|
|
51
|
+
parent_task = find_active_workflow
|
|
52
|
+
return I18n.t("collavre.comments.work_command.no_active_workflow") unless parent_task
|
|
53
|
+
|
|
54
|
+
WorkflowExecutor.new(parent_task).stop!
|
|
55
|
+
I18n.t("collavre.comments.work_command.stopped",
|
|
56
|
+
agent: parent_task.agent.display_name)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# --- /work resume ---
|
|
60
|
+
|
|
61
|
+
def execute_resume
|
|
62
|
+
parent_task = find_resumable_workflow
|
|
63
|
+
return I18n.t("collavre.comments.work_command.no_resumable_workflow") unless parent_task
|
|
64
|
+
|
|
65
|
+
WorkflowExecutor.new(parent_task).resume!
|
|
66
|
+
parent_task.reload
|
|
67
|
+
I18n.t("collavre.comments.work_command.resumed",
|
|
68
|
+
agent: parent_task.agent.display_name,
|
|
69
|
+
remaining: (parent_task.workflow_state["pending_creative_ids"] || []).size)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# --- /work start (default) ---
|
|
73
|
+
|
|
74
|
+
def execute_start
|
|
75
|
+
worker, supervisor = find_agents
|
|
76
|
+
return I18n.t("collavre.comments.work_command.agent_not_found") unless worker
|
|
77
|
+
return I18n.t("collavre.comments.work_command.no_children") if creative.descendants.empty?
|
|
78
|
+
|
|
79
|
+
workflow_text = extract_workflow_context
|
|
80
|
+
child_ids = collect_dfs_creative_ids
|
|
81
|
+
skipped_ids = filter_already_tasked(child_ids)
|
|
82
|
+
pending_ids = child_ids - skipped_ids
|
|
83
|
+
|
|
84
|
+
return I18n.t("collavre.comments.work_command.all_already_tasked") if pending_ids.empty?
|
|
85
|
+
|
|
86
|
+
parent_task = create_parent_task(worker, supervisor, workflow_text, pending_ids)
|
|
87
|
+
WorkflowExecutor.advance!(parent_task)
|
|
88
|
+
|
|
89
|
+
I18n.t("collavre.comments.work_command.started",
|
|
90
|
+
agent: worker.display_name,
|
|
91
|
+
total: pending_ids.size,
|
|
92
|
+
skipped: skipped_ids.size)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Parse mentioned AI agents: first = worker, second = supervisor (optional)
|
|
96
|
+
# Usage: /work @Worker @Supervisor: context
|
|
97
|
+
def find_agents
|
|
98
|
+
mentioned = MentionParser.resolve_all_users(comment.content.to_s).select(&:ai_user?)
|
|
99
|
+
worker = mentioned.first
|
|
100
|
+
supervisor = mentioned.second # nil if only one agent mentioned
|
|
101
|
+
[ worker, supervisor ]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def extract_workflow_context
|
|
105
|
+
content = comment.content.to_s.strip
|
|
106
|
+
content = content.sub(/\A\/work\s+/, "")
|
|
107
|
+
# Strip @mentions using the same pattern as MentionParser
|
|
108
|
+
content = content.gsub(MentionParser::MENTION_PATTERN, "")
|
|
109
|
+
content.strip
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def collect_dfs_creative_ids
|
|
113
|
+
dfs_ids = []
|
|
114
|
+
dfs_traverse(creative) { |c| dfs_ids << c.id }
|
|
115
|
+
dfs_ids.reject { |id| id == creative.id }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def filter_already_tasked(creative_ids)
|
|
119
|
+
filter_active_or_completed(creative_ids)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def create_parent_task(worker, supervisor, workflow_text, pending_ids)
|
|
123
|
+
state = {
|
|
124
|
+
"pending_creative_ids" => pending_ids,
|
|
125
|
+
"completed_creative_ids" => [],
|
|
126
|
+
"current_creative_id" => nil,
|
|
127
|
+
"total" => pending_ids.size
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
# Store supervisor info for WorkflowExecutor to include in trigger comments
|
|
131
|
+
if supervisor
|
|
132
|
+
state["supervisor"] = { "id" => supervisor.id, "name" => supervisor.display_name }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
Task.create!(
|
|
136
|
+
name: "Workflow: #{creative.description&.truncate(50)}",
|
|
137
|
+
status: "running",
|
|
138
|
+
agent: worker,
|
|
139
|
+
creative: creative,
|
|
140
|
+
workflow_context: workflow_text,
|
|
141
|
+
workflow_state: state,
|
|
142
|
+
trigger_event_name: "work_command",
|
|
143
|
+
trigger_event_payload: {
|
|
144
|
+
"creative" => { "id" => creative.id },
|
|
145
|
+
"comment" => { "id" => comment.id, "user_id" => comment.user_id }
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def find_active_workflow
|
|
151
|
+
Task.where(creative: creative, trigger_event_name: "work_command", status: "running")
|
|
152
|
+
.order(created_at: :desc).first
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def find_resumable_workflow
|
|
156
|
+
Task.where(creative: creative, trigger_event_name: "work_command", status: %w[failed cancelled])
|
|
157
|
+
.order(created_at: :desc).first
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
module Comments
|
|
3
|
+
class WorkflowExecutor
|
|
4
|
+
include Concerns::WorkflowSupport
|
|
5
|
+
|
|
6
|
+
def self.advance!(parent_task)
|
|
7
|
+
new(parent_task).advance!
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(parent_task)
|
|
11
|
+
@parent_task = parent_task
|
|
12
|
+
@state = parent_task.workflow_state || {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def advance!
|
|
16
|
+
loop do
|
|
17
|
+
return if @parent_task.status == "cancelled"
|
|
18
|
+
|
|
19
|
+
pending = @state["pending_creative_ids"] || []
|
|
20
|
+
|
|
21
|
+
if pending.empty?
|
|
22
|
+
complete_workflow!
|
|
23
|
+
return
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
next_creative_id = pending.first
|
|
27
|
+
next_creative = Creative.find_by(id: next_creative_id)
|
|
28
|
+
|
|
29
|
+
unless next_creative
|
|
30
|
+
skip_current!
|
|
31
|
+
next
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@state["current_creative_id"] = next_creative_id
|
|
35
|
+
@parent_task.update!(workflow_state: @state)
|
|
36
|
+
|
|
37
|
+
sub_task_context = build_subtask_context(next_creative)
|
|
38
|
+
|
|
39
|
+
sub_task = Task.create!(
|
|
40
|
+
name: "Work on: #{next_creative.description&.truncate(50)}",
|
|
41
|
+
status: "pending",
|
|
42
|
+
agent: @parent_task.agent,
|
|
43
|
+
creative: next_creative,
|
|
44
|
+
parent_task: @parent_task,
|
|
45
|
+
workflow_context: @parent_task.workflow_context,
|
|
46
|
+
trigger_event_name: "workflow_subtask",
|
|
47
|
+
trigger_event_payload: sub_task_context,
|
|
48
|
+
topic_id: nil
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
post_progress_notice(next_creative)
|
|
52
|
+
AiAgentJob.perform_later(sub_task)
|
|
53
|
+
return
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def complete_subtask!(sub_task)
|
|
58
|
+
completed = @state["completed_creative_ids"] || []
|
|
59
|
+
pending = @state["pending_creative_ids"] || []
|
|
60
|
+
|
|
61
|
+
Rails.logger.info(
|
|
62
|
+
"[WorkflowExecutor] complete_subtask! task=#{sub_task.id} creative=#{sub_task.creative_id} " \
|
|
63
|
+
"pending=#{pending.inspect} completed=#{completed.inspect}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
completed << sub_task.creative_id
|
|
67
|
+
pending.delete(sub_task.creative_id)
|
|
68
|
+
|
|
69
|
+
@state["completed_creative_ids"] = completed
|
|
70
|
+
@state["pending_creative_ids"] = pending
|
|
71
|
+
@state["current_creative_id"] = nil
|
|
72
|
+
@parent_task.update!(workflow_state: @state)
|
|
73
|
+
|
|
74
|
+
total = @state["total"] || 1
|
|
75
|
+
progress = completed.size.to_f / total
|
|
76
|
+
@parent_task.creative&.update!(progress: progress.clamp(0.0, 1.0))
|
|
77
|
+
|
|
78
|
+
Rails.logger.info(
|
|
79
|
+
"[WorkflowExecutor] Progress updated: #{completed.size}/#{total} (#{(progress * 100).round}%)"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
post_subtask_completed_notice(sub_task, completed.size, total)
|
|
83
|
+
|
|
84
|
+
advance!
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def fail_subtask!(sub_task, error_message: nil)
|
|
88
|
+
@state["current_creative_id"] = nil
|
|
89
|
+
@parent_task.update!(
|
|
90
|
+
status: "failed",
|
|
91
|
+
workflow_state: @state.merge(
|
|
92
|
+
"failed_creative_id" => sub_task.creative_id,
|
|
93
|
+
"failure_reason" => error_message
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
post_failure_notice(sub_task, error_message)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def stop!
|
|
101
|
+
# Cancel running sub-task if any
|
|
102
|
+
current_sub = @parent_task.sub_tasks.where(status: %w[running queued pending]).first
|
|
103
|
+
current_sub&.update!(status: "cancelled")
|
|
104
|
+
|
|
105
|
+
@state["current_creative_id"] = nil
|
|
106
|
+
@parent_task.update!(
|
|
107
|
+
status: "cancelled",
|
|
108
|
+
workflow_state: @state
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
post_notice(
|
|
112
|
+
I18n.t("collavre.comments.work_command.workflow_stopped",
|
|
113
|
+
agent: @parent_task.agent.display_name,
|
|
114
|
+
completed: (@state["completed_creative_ids"] || []).size,
|
|
115
|
+
remaining: (@state["pending_creative_ids"] || []).size)
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def resume!
|
|
120
|
+
# Re-check pending creatives — some may have been completed manually
|
|
121
|
+
pending = @state["pending_creative_ids"] || []
|
|
122
|
+
pending = refilter_pending(pending)
|
|
123
|
+
@state["pending_creative_ids"] = pending
|
|
124
|
+
@state["current_creative_id"] = nil
|
|
125
|
+
|
|
126
|
+
# Clear failure state
|
|
127
|
+
@state.delete("failed_creative_id")
|
|
128
|
+
@state.delete("failure_reason")
|
|
129
|
+
|
|
130
|
+
@parent_task.update!(
|
|
131
|
+
status: "running",
|
|
132
|
+
workflow_state: @state
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
post_notice(
|
|
136
|
+
I18n.t("collavre.comments.work_command.workflow_resumed",
|
|
137
|
+
agent: @parent_task.agent.display_name,
|
|
138
|
+
remaining: pending.size)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
advance!
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def skip_current!
|
|
147
|
+
pending = @state["pending_creative_ids"] || []
|
|
148
|
+
pending.shift
|
|
149
|
+
@state["pending_creative_ids"] = pending
|
|
150
|
+
@parent_task.update!(workflow_state: @state)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def complete_workflow!
|
|
154
|
+
@parent_task.update!(status: "done")
|
|
155
|
+
@parent_task.creative&.update!(progress: 1.0)
|
|
156
|
+
|
|
157
|
+
post_notice(
|
|
158
|
+
I18n.t("collavre.comments.work_command.workflow_completed",
|
|
159
|
+
agent: @parent_task.agent.display_name,
|
|
160
|
+
completed: (@state["completed_creative_ids"] || []).size)
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def post_progress_notice(creative)
|
|
165
|
+
total = @state["total"] || 1
|
|
166
|
+
completed_count = (@state["completed_creative_ids"] || []).size
|
|
167
|
+
current_index = completed_count + 1
|
|
168
|
+
creative_desc = creative.description&.truncate(50) || "untitled"
|
|
169
|
+
|
|
170
|
+
post_notice(
|
|
171
|
+
I18n.t("collavre.comments.work_command.subtask_started",
|
|
172
|
+
agent: @parent_task.agent.display_name,
|
|
173
|
+
creative: creative_desc,
|
|
174
|
+
current: current_index,
|
|
175
|
+
total: total)
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def post_subtask_completed_notice(sub_task, completed_count, total)
|
|
180
|
+
creative_desc = sub_task.creative&.description&.truncate(50) || "untitled"
|
|
181
|
+
progress_pct = ((completed_count.to_f / total) * 100).round
|
|
182
|
+
|
|
183
|
+
post_notice(
|
|
184
|
+
I18n.t("collavre.comments.work_command.subtask_completed",
|
|
185
|
+
agent: @parent_task.agent.display_name,
|
|
186
|
+
creative: creative_desc,
|
|
187
|
+
completed: completed_count,
|
|
188
|
+
total: total,
|
|
189
|
+
progress: progress_pct)
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def post_failure_notice(sub_task, error_message)
|
|
194
|
+
creative_desc = sub_task.creative&.description&.truncate(50) || "unknown"
|
|
195
|
+
post_notice(
|
|
196
|
+
I18n.t("collavre.comments.work_command.workflow_failed",
|
|
197
|
+
agent: @parent_task.agent.display_name,
|
|
198
|
+
creative: creative_desc,
|
|
199
|
+
reason: error_message || "unknown error")
|
|
200
|
+
)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def post_notice(content)
|
|
204
|
+
post_workflow_notice(@parent_task, content)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def build_subtask_context(creative)
|
|
208
|
+
original_user = find_original_user
|
|
209
|
+
|
|
210
|
+
# Build rich trigger comment with creative content + workflow instruction
|
|
211
|
+
# This mimics a user manually asking the agent in the creative's chat
|
|
212
|
+
trigger_content = build_trigger_content(creative)
|
|
213
|
+
|
|
214
|
+
trigger_comment = creative.comments.create!(
|
|
215
|
+
content: trigger_content,
|
|
216
|
+
user: original_user,
|
|
217
|
+
topic_id: nil
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# Context matches the format MessageBuilder expects:
|
|
221
|
+
# - creative.id → MessageBuilder renders creative tree markdown + chat history
|
|
222
|
+
# - comment.id → points to trigger comment in the child creative
|
|
223
|
+
# - sender → original user who issued /work
|
|
224
|
+
{
|
|
225
|
+
"creative" => { "id" => creative.id, "description" => creative.description },
|
|
226
|
+
"workflow" => {
|
|
227
|
+
"context" => @parent_task.workflow_context,
|
|
228
|
+
"parent_task_id" => @parent_task.id
|
|
229
|
+
},
|
|
230
|
+
"comment" => { "id" => trigger_comment.id, "content" => trigger_comment.content,
|
|
231
|
+
"user_id" => trigger_comment.user_id },
|
|
232
|
+
"sender" => { "name" => original_user.name, "id" => original_user.id }
|
|
233
|
+
}
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def build_trigger_content(_creative)
|
|
237
|
+
# Resolve workflow_context: if it's a creative ID, render that creative's markdown
|
|
238
|
+
# as the instruction (like a user pasting the content). Otherwise use as-is.
|
|
239
|
+
# NOTE: Do NOT prefix with @Agent — the trigger comment is authored by the
|
|
240
|
+
# original /work user, not the agent. Including @Agent causes the agent to
|
|
241
|
+
# interpret it as a self-referencing instruction.
|
|
242
|
+
# MessageBuilder already renders current creative's markdown from context["creative"]["id"].
|
|
243
|
+
content = resolve_workflow_context
|
|
244
|
+
|
|
245
|
+
# If a supervisor is assigned, append instruction to consult them instead of asking the user
|
|
246
|
+
supervisor = @state["supervisor"]
|
|
247
|
+
if supervisor
|
|
248
|
+
supervisor_instruction = I18n.t(
|
|
249
|
+
"collavre.comments.work_command.supervisor_instruction",
|
|
250
|
+
supervisor: supervisor["name"]
|
|
251
|
+
)
|
|
252
|
+
content = "#{content}\n\n#{supervisor_instruction}"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
content
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def resolve_workflow_context
|
|
259
|
+
resolve_workflow_context_from_task(@parent_task, @state)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def render_creative_markdown(creative)
|
|
263
|
+
render_creative_markdown_for_task(@parent_task, creative)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def find_original_user
|
|
267
|
+
find_original_user_from_task(@parent_task)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def refilter_pending(creative_ids)
|
|
271
|
+
return [] if creative_ids.empty?
|
|
272
|
+
creative_ids - filter_active_or_completed(creative_ids)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
@@ -3,9 +3,10 @@ module Creatives
|
|
|
3
3
|
class PlanTagger
|
|
4
4
|
Result = Struct.new(:success?, :message, keyword_init: true)
|
|
5
5
|
|
|
6
|
-
def initialize(plan_id:, creative_ids: [])
|
|
6
|
+
def initialize(plan_id:, creative_ids: [], user: nil)
|
|
7
7
|
@plan = Plan.find_by(id: plan_id)
|
|
8
8
|
@creative_ids = Array(creative_ids).map(&:presence).compact
|
|
9
|
+
@user = user
|
|
9
10
|
end
|
|
10
11
|
|
|
11
12
|
def apply
|
|
@@ -31,10 +32,20 @@ module Creatives
|
|
|
31
32
|
|
|
32
33
|
private
|
|
33
34
|
|
|
34
|
-
attr_reader :plan, :creative_ids
|
|
35
|
+
attr_reader :plan, :creative_ids, :user
|
|
35
36
|
|
|
36
37
|
def creatives
|
|
37
|
-
Creative.where(id: creative_ids)
|
|
38
|
+
all_creatives = Creative.where(id: creative_ids)
|
|
39
|
+
return all_creatives unless user
|
|
40
|
+
|
|
41
|
+
# Scope to creatives the user has write permission for via creative_shares
|
|
42
|
+
permitted_ids = all_creatives.joins(:creative_shares)
|
|
43
|
+
.where(creative_shares: { user_id: user.id })
|
|
44
|
+
.where("creative_shares.permission IN (?)", %w[write admin])
|
|
45
|
+
.pluck(:id)
|
|
46
|
+
# Also include creatives owned by the user
|
|
47
|
+
owned_ids = all_creatives.where(user_id: user.id).pluck(:id)
|
|
48
|
+
Creative.where(id: (permitted_ids + owned_ids).uniq)
|
|
38
49
|
end
|
|
39
50
|
|
|
40
51
|
def valid?
|
|
@@ -1,9 +1,31 @@
|
|
|
1
1
|
module Collavre
|
|
2
2
|
module Creatives
|
|
3
|
+
# Compact markdown tree formatter for AI Agent consumption.
|
|
4
|
+
# Used by: creative_retrieval_service, GeminiParentRecommender, Agent context injection.
|
|
5
|
+
#
|
|
6
|
+
# Output format (header declared once, rows are values only):
|
|
7
|
+
# <!-- format: [id] description (progress%) -->
|
|
8
|
+
# - [123] My Task (50%)
|
|
9
|
+
# - [124] Subtask A (100%)
|
|
10
|
+
# - [125] Subtask B (0%)
|
|
11
|
+
#
|
|
12
|
+
# Options:
|
|
13
|
+
# max_depth: Max tree depth (nil = unlimited)
|
|
14
|
+
# include_header: Include format comment header (default: true)
|
|
15
|
+
# include_comments: Include recent comments per node (default: false)
|
|
16
|
+
# use_permissions: Use permission-filtered children (default: true)
|
|
3
17
|
class TreeFormatter
|
|
18
|
+
def initialize(max_depth: nil, include_header: true, include_comments: false, use_permissions: true)
|
|
19
|
+
@max_depth = max_depth
|
|
20
|
+
@include_header = include_header
|
|
21
|
+
@include_comments = include_comments
|
|
22
|
+
@use_permissions = use_permissions
|
|
23
|
+
end
|
|
24
|
+
|
|
4
25
|
def format(creatives)
|
|
5
26
|
roots = Array(creatives)
|
|
6
27
|
lines = []
|
|
28
|
+
lines << "<!-- format: [id] description (progress%) -->" if @include_header
|
|
7
29
|
|
|
8
30
|
roots.each do |root|
|
|
9
31
|
format_node(root, 0, lines)
|
|
@@ -12,25 +34,43 @@ module Collavre
|
|
|
12
34
|
lines.join("\n")
|
|
13
35
|
end
|
|
14
36
|
|
|
37
|
+
# Extract plain text description from a creative (shared helper)
|
|
38
|
+
def self.plain_description(creative)
|
|
39
|
+
raw = creative.effective_description(nil, true)
|
|
40
|
+
ActionView::Base.full_sanitizer.sanitize(raw).strip
|
|
41
|
+
end
|
|
42
|
+
|
|
15
43
|
private
|
|
16
44
|
|
|
17
45
|
def format_node(node, depth, lines)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
46
|
+
return if @max_depth && depth > @max_depth
|
|
47
|
+
|
|
48
|
+
indent = " " * depth
|
|
49
|
+
desc = self.class.plain_description(node)
|
|
50
|
+
progress = ((node.progress || 0.0) * 100).round
|
|
51
|
+
|
|
52
|
+
lines << "#{indent}- [#{node.id}] #{desc} (#{progress}%)"
|
|
53
|
+
|
|
54
|
+
if @include_comments
|
|
55
|
+
node.comments.order(created_at: :desc).limit(3).reverse_each do |comment|
|
|
56
|
+
comment_text = ActionView::Base.full_sanitizer.sanitize(comment.content).strip.truncate(100)
|
|
57
|
+
lines << "#{indent} > #{comment_text}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
children_for(node).each do |child|
|
|
31
62
|
format_node(child, depth + 1, lines)
|
|
32
63
|
end
|
|
33
64
|
end
|
|
65
|
+
|
|
66
|
+
def children_for(node)
|
|
67
|
+
if @use_permissions
|
|
68
|
+
node.linked_children
|
|
69
|
+
else
|
|
70
|
+
# When children are pre-set on association target (e.g. GeminiParentRecommender)
|
|
71
|
+
node.children
|
|
72
|
+
end
|
|
73
|
+
end
|
|
34
74
|
end
|
|
35
75
|
end
|
|
36
76
|
end
|
|
@@ -33,9 +33,9 @@ module Collavre
|
|
|
33
33
|
category_ids = categories.index_by(&:id)
|
|
34
34
|
top_level_categories = categories.reject { |c| category_ids.key?(c.parent_id) }
|
|
35
35
|
|
|
36
|
-
tree_text = Creatives::TreeFormatter.new.format(top_level_categories)
|
|
36
|
+
tree_text = Creatives::TreeFormatter.new(use_permissions: false).format(top_level_categories)
|
|
37
37
|
|
|
38
|
-
prompt = build_prompt(tree_text,
|
|
38
|
+
prompt = build_prompt(tree_text, Creatives::TreeFormatter.plain_description(creative))
|
|
39
39
|
Rails.logger.info("### prompt=#{prompt}")
|
|
40
40
|
|
|
41
41
|
response = @client.chat([ { role: :user, parts: [ { text: prompt } ] } ])
|
|
@@ -45,7 +45,7 @@ module Collavre
|
|
|
45
45
|
ids.map do |id|
|
|
46
46
|
c = Creative.find_by(id: id)
|
|
47
47
|
next unless c
|
|
48
|
-
path = c.ancestors.reverse.map { |a|
|
|
48
|
+
path = c.ancestors.reverse.map { |a| Creatives::TreeFormatter.plain_description(a) } + [ Creatives::TreeFormatter.plain_description(c) ]
|
|
49
49
|
{ id: id, path: path.join(" > ") }
|
|
50
50
|
end.compact
|
|
51
51
|
end
|
|
@@ -55,7 +55,7 @@ module Collavre
|
|
|
55
55
|
def default_client
|
|
56
56
|
AiClient.new(
|
|
57
57
|
vendor: "google",
|
|
58
|
-
model: "gemini-
|
|
58
|
+
model: "gemini-3-flash-preview",
|
|
59
59
|
system_prompt: nil
|
|
60
60
|
)
|
|
61
61
|
end
|
|
@@ -111,9 +111,7 @@ module Collavre
|
|
|
111
111
|
sections << I18n.t("collavre.ai_agent.collaboration.rules_header")
|
|
112
112
|
sections << (collab["mention_rule"] || I18n.t("collavre.ai_agent.collaboration.mention_rule"))
|
|
113
113
|
sections << (collab["confidence_rule"] || I18n.t("collavre.ai_agent.collaboration.confidence_rule"))
|
|
114
|
-
|
|
115
|
-
sections << I18n.t("collavre.ai_agent.collaboration.confidence_format_instruction")
|
|
116
|
-
end
|
|
114
|
+
sections << I18n.t("collavre.ai_agent.collaboration.confidence_format_instruction")
|
|
117
115
|
sections << (collab["escalation_rule"] || I18n.t("collavre.ai_agent.collaboration.escalation_rule"))
|
|
118
116
|
sections << (collab["review_rule"] || I18n.t("collavre.ai_agent.collaboration.review_rule"))
|
|
119
117
|
sections << ""
|
|
@@ -16,8 +16,8 @@ module Collavre
|
|
|
16
16
|
new(event_name: event_name, context: context).dispatch
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def self.dequeue_next_for_topic(topic_id)
|
|
20
|
-
task = Task.queued_for_topic(topic_id).first
|
|
19
|
+
def self.dequeue_next_for_topic(topic_id, creative_id = nil)
|
|
20
|
+
task = Task.queued_for_topic(topic_id, creative_id).first
|
|
21
21
|
return unless task
|
|
22
22
|
|
|
23
23
|
updated = Task.where(id: task.id, status: "queued").update_all(status: "pending")
|
|
@@ -29,7 +29,7 @@ module Collavre
|
|
|
29
29
|
if task.status == "cancelled"
|
|
30
30
|
# refresh_deferred_context! cancelled this task (no eligible comment),
|
|
31
31
|
# try the next queued task for this topic.
|
|
32
|
-
dequeue_next_for_topic(topic_id)
|
|
32
|
+
dequeue_next_for_topic(topic_id, creative_id)
|
|
33
33
|
else
|
|
34
34
|
AiAgentJob.perform_later(task)
|
|
35
35
|
end
|
|
@@ -127,6 +127,16 @@ module Collavre
|
|
|
127
127
|
agent = decision[:agent]
|
|
128
128
|
log_decision(decision)
|
|
129
129
|
|
|
130
|
+
# Guard: skip if agent already has a running task for this comment
|
|
131
|
+
comment_id = @context.dig("comment", "id")
|
|
132
|
+
if comment_id && Task.duplicate_running_for_comment?(agent.id, comment_id)
|
|
133
|
+
Rails.logger.warn(
|
|
134
|
+
"[AgentOrchestrator] Skipping enqueue: agent #{agent.id} already has a running task " \
|
|
135
|
+
"for comment #{comment_id}"
|
|
136
|
+
)
|
|
137
|
+
next
|
|
138
|
+
end
|
|
139
|
+
|
|
130
140
|
case decision[:timing]
|
|
131
141
|
when :immediate
|
|
132
142
|
AiAgentJob.perform_later(agent.id, @event_name, @context)
|
|
@@ -138,7 +148,8 @@ module Collavre
|
|
|
138
148
|
trigger_event_name: @event_name,
|
|
139
149
|
trigger_event_payload: @context,
|
|
140
150
|
agent: agent,
|
|
141
|
-
topic_id: @context.dig("topic", "id")
|
|
151
|
+
topic_id: @context.dig("topic", "id"),
|
|
152
|
+
creative_id: @context.dig("creative", "id")
|
|
142
153
|
)
|
|
143
154
|
post_waiting_notice(agent, decision)
|
|
144
155
|
agent
|
|
@@ -28,11 +28,6 @@ module Collavre
|
|
|
28
28
|
"rate_limit_per_minute" => 20,
|
|
29
29
|
"backoff_strategy" => "exponential",
|
|
30
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
31
|
# Loop breaker settings
|
|
37
32
|
"loop_breaker_enabled" => true,
|
|
38
33
|
"ping_pong_threshold" => 5, # Max back-and-forth between same agents
|
|
@@ -113,20 +108,6 @@ module Collavre
|
|
|
113
108
|
arbitration_config["bid_fallback_enabled"] != false
|
|
114
109
|
end
|
|
115
110
|
|
|
116
|
-
# Self-reflection settings
|
|
117
|
-
def self_reflection_enabled?
|
|
118
|
-
scheduling_config["self_reflection_enabled"] == true
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
def self_reflection_config
|
|
122
|
-
{
|
|
123
|
-
"enabled" => scheduling_config["self_reflection_enabled"],
|
|
124
|
-
"confidence_threshold" => scheduling_config["confidence_threshold"],
|
|
125
|
-
"max_retries" => scheduling_config["max_retries"],
|
|
126
|
-
"retry_delay_seconds" => scheduling_config["retry_delay_seconds"]
|
|
127
|
-
}
|
|
128
|
-
end
|
|
129
|
-
|
|
130
111
|
# Loop breaker settings
|
|
131
112
|
def loop_breaker_enabled?
|
|
132
113
|
scheduling_config["loop_breaker_enabled"] == true
|