collavre 0.3.2 → 0.5.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/actiontext.css +73 -71
- data/app/assets/stylesheets/collavre/activity_logs.css +18 -45
- data/app/assets/stylesheets/collavre/comments_popup.css +197 -35
- data/app/assets/stylesheets/collavre/creatives.css +101 -51
- data/app/assets/stylesheets/collavre/dark_mode.css +221 -88
- data/app/assets/stylesheets/collavre/design_tokens.css +334 -0
- data/app/assets/stylesheets/collavre/mention_menu.css +13 -9
- data/app/assets/stylesheets/collavre/popup.css +57 -27
- data/app/assets/stylesheets/collavre/slide_view.css +6 -6
- data/app/assets/stylesheets/collavre/user_menu.css +4 -5
- data/app/components/collavre/plans_timeline_component.html.erb +2 -2
- data/app/controllers/collavre/admin/orchestration_controller.rb +9 -2
- data/app/controllers/collavre/admin/settings_controller.rb +199 -0
- data/app/controllers/collavre/comments/reactions_controller.rb +1 -9
- data/app/controllers/collavre/comments_controller.rb +39 -162
- data/app/controllers/collavre/creatives_controller.rb +18 -58
- data/app/controllers/collavre/users_controller.rb +31 -3
- data/app/helpers/collavre/application_helper.rb +97 -0
- data/app/helpers/collavre/creatives_helper.rb +10 -202
- data/app/javascript/collavre.js +0 -1
- data/app/javascript/components/creative_tree_row.js +3 -2
- data/app/javascript/controllers/comment_controller.js +309 -4
- data/app/javascript/controllers/comments/form_controller.js +52 -0
- data/app/javascript/controllers/comments/presence_controller.js +13 -0
- data/app/javascript/controllers/creatives/tree_controller.js +2 -1
- data/app/javascript/controllers/link_creative_controller.js +29 -3
- data/app/javascript/lib/__tests__/html_code_block_wrapper.test.js +201 -0
- data/app/javascript/lib/html_code_block_wrapper.js +168 -0
- data/app/javascript/lib/utils/markdown.js +2 -1
- data/app/javascript/modules/creative_row_editor.js +5 -1
- data/app/javascript/utils/emoji_parser.js +21 -0
- data/app/jobs/collavre/ai_agent_job.rb +6 -2
- data/app/jobs/collavre/cron_action_job.rb +18 -6
- data/app/jobs/collavre/cron_scheduler_job.rb +112 -0
- data/app/models/collavre/comment/approvable.rb +50 -0
- data/app/models/collavre/comment/broadcastable.rb +119 -0
- data/app/models/collavre/comment/notifiable.rb +111 -0
- data/app/models/collavre/comment.rb +13 -258
- data/app/models/collavre/comment_reaction.rb +15 -0
- data/app/models/collavre/creative/describable.rb +86 -0
- data/app/models/collavre/creative/linkable.rb +77 -0
- data/app/models/collavre/creative/permissible.rb +103 -0
- data/app/models/collavre/creative.rb +3 -289
- data/app/models/collavre/orchestrator_policy.rb +1 -1
- data/app/models/collavre/system_setting.rb +27 -1
- data/app/models/collavre/user.rb +42 -0
- data/app/models/collavre/user_theme.rb +10 -0
- data/app/services/collavre/ai_agent/approval_handler.rb +110 -0
- data/app/services/collavre/ai_agent/message_builder.rb +129 -0
- data/app/services/collavre/ai_agent/review_handler.rb +70 -0
- data/app/services/collavre/ai_agent_service.rb +93 -150
- data/app/services/collavre/ai_client.rb +23 -4
- data/app/services/collavre/auto_theme_generator.rb +168 -50
- data/app/services/collavre/command_menu_service.rb +70 -0
- data/app/services/collavre/comment_move_service.rb +94 -0
- data/app/services/collavre/comments/action_executor.rb +10 -0
- data/app/services/collavre/comments/mcp_command.rb +1 -2
- data/app/services/collavre/creatives/create_service.rb +86 -0
- data/app/services/collavre/creatives/destroy_service.rb +41 -0
- data/app/services/collavre/creatives/index_query.rb +3 -0
- data/app/services/collavre/markdown_converter.rb +240 -0
- data/app/services/collavre/mention_parser.rb +63 -0
- data/app/services/collavre/orchestration/agent_context_builder.rb +24 -8
- data/app/services/collavre/orchestration/agent_orchestrator.rb +59 -10
- data/app/services/collavre/orchestration/loop_breaker.rb +12 -7
- data/app/services/collavre/orchestration/policy_resolver.rb +16 -2
- data/app/services/collavre/orchestration/scheduler.rb +4 -3
- data/app/services/collavre/orchestration/stuck_detector.rb +1 -1
- data/app/services/collavre/system_events/context_builder.rb +1 -6
- data/app/services/collavre/tools/creative_batch_service.rb +107 -0
- data/app/services/collavre/tools/creative_update_service.rb +17 -12
- data/app/services/collavre/tools/cron_create_service.rb +17 -5
- data/app/views/admin/shared/_tabs.html.erb +2 -1
- data/app/views/collavre/admin/orchestration/show.html.erb +11 -0
- data/app/views/collavre/admin/settings/_system_tab.html.erb +138 -0
- data/app/views/collavre/admin/settings/_uiux_tab.html.erb +44 -0
- data/app/views/collavre/admin/settings/index.html.erb +11 -0
- data/app/views/collavre/admin/settings/uiux.html.erb +11 -0
- data/app/views/collavre/comments/_comment.html.erb +15 -5
- data/app/views/collavre/comments/_comments_popup.html.erb +9 -2
- data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +0 -3
- data/app/views/collavre/creatives/_share_button.html.erb +0 -52
- data/app/views/collavre/creatives/_share_modal.html.erb +52 -0
- data/app/views/collavre/creatives/index.html.erb +5 -8
- data/app/views/collavre/shared/navigation/_panels.html.erb +2 -2
- data/app/views/collavre/user_themes/index.html.erb +7 -9
- data/app/views/collavre/users/_contact_management.html.erb +2 -1
- data/app/views/collavre/users/edit_ai.html.erb +7 -0
- data/app/views/collavre/users/index.html.erb +16 -1
- data/app/views/collavre/users/new_ai.html.erb +18 -8
- data/app/views/collavre/users/passkeys.html.erb +1 -1
- data/app/views/collavre/users/show.html.erb +1 -1
- data/app/views/layouts/collavre/slide.html.erb +8 -1
- data/config/locales/admin.en.yml +88 -0
- data/config/locales/admin.ko.yml +88 -0
- data/config/locales/ai_agent.en.yml +5 -1
- data/config/locales/ai_agent.ko.yml +5 -1
- data/config/locales/comments.en.yml +5 -1
- data/config/locales/comments.ko.yml +5 -1
- data/config/locales/orchestration.en.yml +8 -0
- data/config/locales/orchestration.ko.yml +8 -0
- data/config/locales/users.en.yml +12 -0
- data/config/locales/users.ko.yml +12 -0
- data/config/routes.rb +7 -1
- data/db/migrate/20260212011655_add_quoted_comment_to_comments.rb +7 -0
- data/db/migrate/20260213044247_add_agent_conf_to_users.rb +5 -0
- data/lib/collavre/engine.rb +25 -0
- data/lib/collavre/version.rb +1 -1
- metadata +32 -1
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module Collavre
|
|
6
|
+
# Converts between Markdown and HTML for creative descriptions.
|
|
7
|
+
#
|
|
8
|
+
# Extracted from CreativesHelper to keep conversion logic testable
|
|
9
|
+
# outside of view contexts and reusable from services (e.g. MarkdownImporter).
|
|
10
|
+
class MarkdownConverter
|
|
11
|
+
class << self
|
|
12
|
+
# Convert lightweight Markdown (links, bold, images) to HTML.
|
|
13
|
+
def markdown_to_html(text, image_refs = {})
|
|
14
|
+
return "" if text.nil?
|
|
15
|
+
html = text.dup
|
|
16
|
+
|
|
17
|
+
# Collect reference-style data-URI images: [alt]: <data:...>
|
|
18
|
+
html.gsub!(/^\s*\[([^\]]+)\]:\s*<\s*(data:image\/[^>]+)\s*>\s*$/) do
|
|
19
|
+
image_refs[$1] = $2.strip
|
|
20
|
+
""
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Reference-style images: ![alt][ref]
|
|
24
|
+
html.gsub!(/(?<!\\)!\[([^\]]*)\]\[([^\]]+)\]/) do
|
|
25
|
+
if (data_url = image_refs[$2])
|
|
26
|
+
data_image_to_attachment(data_url, $1)
|
|
27
|
+
else
|
|
28
|
+
"![#{$1}][#{$2}]"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Inline data-URI images: 
|
|
33
|
+
html.gsub!(/(?<!\\)!\[([^\]]*)\]\((data:image\/[^)]+)\)/) do
|
|
34
|
+
data_image_to_attachment($2, $1)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Inline links: [text](url)
|
|
38
|
+
html.gsub!(/(?<!\\)\[([^\]]+)\]\(([^)]+)\)/) do
|
|
39
|
+
"<a href=\"#{$2}\">#{$1}</a>"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Bold: **text** or __text__
|
|
43
|
+
html.gsub!(/(?<!\\)(\*\*|__)(.+?)\1/m) do
|
|
44
|
+
"<strong>#{$2}</strong>"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Unescape backslash-escaped chars
|
|
48
|
+
html.gsub!(/\\([\\*_\[\]()!#~+\-])/, '\\1')
|
|
49
|
+
html.gsub!(/\\\\/, "\\")
|
|
50
|
+
html.strip!
|
|
51
|
+
html
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Convert HTML (links, bold, images, tables) back to Markdown.
|
|
55
|
+
def html_to_markdown(text)
|
|
56
|
+
return "" if text.nil?
|
|
57
|
+
markdown = text.dup
|
|
58
|
+
placeholders = {}
|
|
59
|
+
index = 0
|
|
60
|
+
|
|
61
|
+
# Tables → Markdown tables
|
|
62
|
+
markdown.gsub!(%r{<table\b[^>]*>.*?</table>}im) do |match|
|
|
63
|
+
token = "__TABLE#{index}__"; index += 1
|
|
64
|
+
placeholders[token] = table_to_markdown(match)
|
|
65
|
+
token
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Action-text attachments → data-URI images
|
|
69
|
+
markdown.gsub!(%r{<action-text-attachment ([^>]+)>(?:</action-text-attachment>)?}) do |_match|
|
|
70
|
+
attrs = Hash[$1.scan(/(\S+?)="([^"]*)"/)]
|
|
71
|
+
sgid = attrs["sgid"]
|
|
72
|
+
caption = attrs["caption"] || ""
|
|
73
|
+
if (blob = GlobalID::Locator.locate_signed(sgid, for: "attachable"))
|
|
74
|
+
data = Base64.strict_encode64(blob.download)
|
|
75
|
+
token = "__IMG#{index}__"; index += 1
|
|
76
|
+
placeholders[token] = ""
|
|
77
|
+
token
|
|
78
|
+
else
|
|
79
|
+
""
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Active Storage blob images → data-URI
|
|
84
|
+
markdown.gsub!(%r{<img [^>]*src=["'](/rails/active_storage/blobs/[^"']+)["'][^>]*alt=["']([^"']*)["'][^>]*>}) do |match|
|
|
85
|
+
blob_path = $1
|
|
86
|
+
alt_text = $2
|
|
87
|
+
if blob_path =~ %r{/rails/active_storage/blobs/(?:redirect|proxy)/([^/]+)/}
|
|
88
|
+
signed_id = $1
|
|
89
|
+
begin
|
|
90
|
+
blob = ActiveStorage::Blob.find_signed(signed_id)
|
|
91
|
+
data = Base64.strict_encode64(blob.download)
|
|
92
|
+
token = "__IMG#{index}__"; index += 1
|
|
93
|
+
placeholders[token] = ""
|
|
94
|
+
token
|
|
95
|
+
rescue
|
|
96
|
+
match
|
|
97
|
+
end
|
|
98
|
+
else
|
|
99
|
+
match
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Inline data-URI images (src before alt)
|
|
104
|
+
markdown.gsub!(/<img [^>]*src=['"](data:[^'"]+)['"][^>]*alt=['"]([^'"]*)['"][^>]*>/) do
|
|
105
|
+
token = "__IMG#{index}__"; index += 1
|
|
106
|
+
placeholders[token] = ""
|
|
107
|
+
token
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Inline data-URI images (alt before src)
|
|
111
|
+
markdown.gsub!(/<img [^>]*alt=['"]([^'"]*)['"][^>]*src=['"](data:[^'"]+)['"][^>]*>/) do
|
|
112
|
+
token = "__IMG#{index}__"; index += 1
|
|
113
|
+
placeholders[token] = ""
|
|
114
|
+
token
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Links → [text](url)
|
|
118
|
+
markdown.gsub!(/<a [^>]*href=['"]([^'"]+)['"][^>]*>(.*?)<\/a>/m) do
|
|
119
|
+
inner = ActionView::Base.full_sanitizer.sanitize($2)
|
|
120
|
+
token = "__LINK#{index}__"; index += 1
|
|
121
|
+
placeholders[token] = "[#{inner}](#{$1})"
|
|
122
|
+
token
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Bold → **text**
|
|
126
|
+
markdown.gsub!(/<(strong|b)(?:\s+[^>]*)?>(.*?)<\/\1>/im) do
|
|
127
|
+
token = "__BOLD#{index}__"; index += 1
|
|
128
|
+
placeholders[token] = "**#{$2.strip}**"
|
|
129
|
+
token
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Escape markdown special chars in remaining text
|
|
133
|
+
markdown.gsub!(/([\\*\[\]()!#~+\-])/) { "\\#{$1}" }
|
|
134
|
+
|
|
135
|
+
# Restore placeholders
|
|
136
|
+
placeholders.each { |k, v| markdown.gsub!(k, v) }
|
|
137
|
+
|
|
138
|
+
# Strip remaining HTML tags
|
|
139
|
+
markdown.gsub!(/<[^>]+>/, "")
|
|
140
|
+
markdown
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Check whether text looks like a Markdown table block.
|
|
144
|
+
def table_block?(text)
|
|
145
|
+
lines = text.to_s.strip.split("\n")
|
|
146
|
+
return false if lines.length < 2
|
|
147
|
+
|
|
148
|
+
header_line = lines[0]
|
|
149
|
+
alignment_line = lines[1]
|
|
150
|
+
return false unless header_line.match?(/\A\|.*\|\z/)
|
|
151
|
+
return false unless alignment_line.match?(/\A\|[ \-:\|]+\|\z/)
|
|
152
|
+
|
|
153
|
+
true
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Convert an HTML <table> fragment to a Markdown table string.
|
|
157
|
+
def table_to_markdown(table_html)
|
|
158
|
+
fragment = Nokogiri::HTML::DocumentFragment.parse(table_html)
|
|
159
|
+
table = fragment.at_css("table")
|
|
160
|
+
return "" unless table
|
|
161
|
+
|
|
162
|
+
header_row = table.at_css("thead tr") || table.css("tr").first
|
|
163
|
+
return "" unless header_row
|
|
164
|
+
|
|
165
|
+
header_cells = header_row.css("th,td")
|
|
166
|
+
headers = header_cells.map { |cell| escape_table_cell(html_to_markdown(cell.inner_html).strip) }
|
|
167
|
+
alignments = header_cells.map { |cell| alignment_from_cell(cell) }
|
|
168
|
+
|
|
169
|
+
body_rows = table.css("tbody tr")
|
|
170
|
+
if body_rows.empty?
|
|
171
|
+
all_rows = table.css("tr")
|
|
172
|
+
body_rows = all_rows.drop(1)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
body_lines = body_rows.map do |row|
|
|
176
|
+
cells = row.css("th,td").map { |cell| escape_table_cell(html_to_markdown(cell.inner_html).strip) }
|
|
177
|
+
normalized = pad_cells(cells, headers.length)
|
|
178
|
+
"| #{normalized.join(' | ')} |"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
alignment_cells = pad_cells(alignments, headers.length).map { |align| alignment_marker(align) }
|
|
182
|
+
header_line = "| #{headers.map(&:strip).join(' | ')} |"
|
|
183
|
+
alignment_line = "| #{alignment_cells.join(' | ')} |"
|
|
184
|
+
|
|
185
|
+
([ header_line, alignment_line ] + body_lines).join("\n")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Convert a data-URI image to an Active Storage attachment <img> tag.
|
|
189
|
+
def data_image_to_attachment(data_url, alt)
|
|
190
|
+
if data_url =~ %r{\Adata:(image/[\w.+-]+);base64,(.+)\z}
|
|
191
|
+
content_type = Regexp.last_match(1)
|
|
192
|
+
data = Base64.decode64(Regexp.last_match(2))
|
|
193
|
+
ext = Mime::Type.lookup(content_type).symbol.to_s
|
|
194
|
+
filename = "import-#{SecureRandom.hex}.#{ext}"
|
|
195
|
+
blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new(data), filename: filename, content_type: content_type)
|
|
196
|
+
"<img src=\"#{Rails.application.routes.url_helpers.rails_blob_url(blob, only_path: true)}\" alt=\"#{alt}\" />"
|
|
197
|
+
else
|
|
198
|
+
"<img src=\"#{data_url}\" alt=\"#{alt}\" />"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
private
|
|
203
|
+
|
|
204
|
+
def escape_table_cell(text)
|
|
205
|
+
text.to_s.gsub(/(?<!\\)\|/, '\\|')
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def alignment_from_cell(cell)
|
|
209
|
+
style = cell["style"].to_s
|
|
210
|
+
align = cell["align"].to_s
|
|
211
|
+
case
|
|
212
|
+
when style =~ /text-align\s*:\s*center/i || align =~ /center/i
|
|
213
|
+
:center
|
|
214
|
+
when style =~ /text-align\s*:\s*right/i || align =~ /right/i
|
|
215
|
+
:right
|
|
216
|
+
when style =~ /text-align\s*:\s*left/i || align =~ /left/i
|
|
217
|
+
:left
|
|
218
|
+
else
|
|
219
|
+
nil
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def alignment_marker(alignment)
|
|
224
|
+
case alignment
|
|
225
|
+
when :center then ":---:"
|
|
226
|
+
when :right then "---:"
|
|
227
|
+
when :left then ":---"
|
|
228
|
+
else "---"
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def pad_cells(cells, expected_length)
|
|
233
|
+
values = cells.dup
|
|
234
|
+
values = values.first(expected_length)
|
|
235
|
+
values.fill("", values.length...expected_length)
|
|
236
|
+
values
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
# Centralized mention parsing and resolution.
|
|
3
|
+
# All @mention logic should go through this module so changes
|
|
4
|
+
# to the mention format only need to be made in one place.
|
|
5
|
+
module MentionParser
|
|
6
|
+
# Characters allowed before @ in mentions (besides start-of-text)
|
|
7
|
+
MENTION_PREFIX_CHARS = /[\s:.,;\n\r]/
|
|
8
|
+
|
|
9
|
+
# Canonical mention: @name: (with colon separator)
|
|
10
|
+
# Matches at start of text or after whitespace/punctuation/newline
|
|
11
|
+
MENTION_PATTERN = /(?:\A|(?<=#{MENTION_PREFIX_CHARS}))@([^:]+?):\s*/
|
|
12
|
+
|
|
13
|
+
# Mention without colon: @name followed by whitespace (start-of-text only
|
|
14
|
+
# to avoid false positives like email addresses)
|
|
15
|
+
MENTION_LOOSE_PATTERN = /\A@(\S+)\s+/
|
|
16
|
+
|
|
17
|
+
# Scan pattern: finds all @name: mentions anywhere in text
|
|
18
|
+
MENTION_SCAN_PATTERN = /(?:^|(?<=#{MENTION_PREFIX_CHARS}))@([^:]+?):/
|
|
19
|
+
|
|
20
|
+
# Extract the first mentioned name from text (returns nil if no mention found)
|
|
21
|
+
def self.extract_name(text)
|
|
22
|
+
return nil if text.blank?
|
|
23
|
+
|
|
24
|
+
match = text.match(MENTION_PATTERN) || text.match(MENTION_LOOSE_PATTERN)
|
|
25
|
+
match ? match[1].strip : nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Extract all mentioned names from text
|
|
29
|
+
def self.extract_all_names(text)
|
|
30
|
+
return [] if text.blank?
|
|
31
|
+
|
|
32
|
+
text.scan(MENTION_SCAN_PATTERN).flatten.map(&:strip).uniq
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Find a User by case-insensitive name match
|
|
36
|
+
def self.find_user_by_name(name)
|
|
37
|
+
return nil if name.blank?
|
|
38
|
+
|
|
39
|
+
User.where("LOWER(name) = ?", name.strip.downcase).first
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Extract mention and resolve to a User in one step
|
|
43
|
+
def self.resolve_user(text)
|
|
44
|
+
name = extract_name(text)
|
|
45
|
+
name ? find_user_by_name(name) : nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Resolve all mentioned users from text (finds mentions anywhere)
|
|
49
|
+
def self.resolve_all_users(text)
|
|
50
|
+
extract_all_names(text).filter_map { |name| find_user_by_name(name) }.uniq
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Strip self-mention prefix from text (both @name: and @name formats)
|
|
54
|
+
def self.strip_self_mention(text, agent_name)
|
|
55
|
+
return text if text.blank? || agent_name.blank?
|
|
56
|
+
|
|
57
|
+
escaped = Regexp.escape(agent_name)
|
|
58
|
+
text
|
|
59
|
+
.sub(/\A@#{escaped}:\s*/i, "")
|
|
60
|
+
.sub(/\A@#{escaped}\s+/i, "")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -19,10 +19,11 @@ module Collavre
|
|
|
19
19
|
"read" => "reference" # Can request information from
|
|
20
20
|
}.freeze
|
|
21
21
|
|
|
22
|
-
def initialize(agent:, creative:, sender: nil)
|
|
22
|
+
def initialize(agent:, creative:, sender: nil, policy_resolver: nil)
|
|
23
23
|
@agent = agent
|
|
24
24
|
@creative = creative
|
|
25
25
|
@sender = sender
|
|
26
|
+
@policy_resolver = policy_resolver
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
def a2a_request?
|
|
@@ -45,6 +46,7 @@ module Collavre
|
|
|
45
46
|
# Generates markdown-formatted collaboration section for system prompt
|
|
46
47
|
def to_collaboration_prompt
|
|
47
48
|
sections = []
|
|
49
|
+
collab = collaboration_policy_config
|
|
48
50
|
|
|
49
51
|
# Add A2A request context if this is an agent-to-agent call
|
|
50
52
|
if a2a_request?
|
|
@@ -52,9 +54,16 @@ module Collavre
|
|
|
52
54
|
sections << ""
|
|
53
55
|
sections << I18n.t("collavre.ai_agent.a2a.request_description",
|
|
54
56
|
sender_name: @sender["name"], sender_type: @sender["type"])
|
|
55
|
-
sections << I18n.t("collavre.ai_agent.a2a.focus_instruction")
|
|
56
|
-
sections <<
|
|
57
|
-
|
|
57
|
+
sections << (collab["a2a_focus_instruction"] || I18n.t("collavre.ai_agent.a2a.focus_instruction"))
|
|
58
|
+
sections << (collab["a2a_completion_instruction"] ||
|
|
59
|
+
I18n.t("collavre.ai_agent.a2a.completion_instruction",
|
|
60
|
+
sender_name: @sender["name"]))
|
|
61
|
+
sections << (collab["a2a_followup_instruction"] || I18n.t("collavre.ai_agent.a2a.followup_instruction"))
|
|
62
|
+
sections << ""
|
|
63
|
+
elsif @sender
|
|
64
|
+
# Human-triggered request: instruct agent to report back
|
|
65
|
+
sections << I18n.t("collavre.ai_agent.a2a.human_completion_instruction",
|
|
66
|
+
sender_name: @sender["name"])
|
|
58
67
|
sections << ""
|
|
59
68
|
end
|
|
60
69
|
|
|
@@ -100,10 +109,13 @@ module Collavre
|
|
|
100
109
|
end
|
|
101
110
|
|
|
102
111
|
sections << I18n.t("collavre.ai_agent.collaboration.rules_header")
|
|
103
|
-
sections << I18n.t("collavre.ai_agent.collaboration.mention_rule")
|
|
104
|
-
sections << I18n.t("collavre.ai_agent.collaboration.confidence_rule")
|
|
105
|
-
|
|
106
|
-
|
|
112
|
+
sections << (collab["mention_rule"] || I18n.t("collavre.ai_agent.collaboration.mention_rule"))
|
|
113
|
+
sections << (collab["confidence_rule"] || I18n.t("collavre.ai_agent.collaboration.confidence_rule"))
|
|
114
|
+
if @policy_resolver&.self_reflection_enabled?
|
|
115
|
+
sections << I18n.t("collavre.ai_agent.collaboration.confidence_format_instruction")
|
|
116
|
+
end
|
|
117
|
+
sections << (collab["escalation_rule"] || I18n.t("collavre.ai_agent.collaboration.escalation_rule"))
|
|
118
|
+
sections << (collab["review_rule"] || I18n.t("collavre.ai_agent.collaboration.review_rule"))
|
|
107
119
|
sections << ""
|
|
108
120
|
|
|
109
121
|
sections.join("\n")
|
|
@@ -196,6 +208,10 @@ module Collavre
|
|
|
196
208
|
"review_hint" => I18n.t("collavre.ai_agent.collaboration.review_rule")
|
|
197
209
|
}
|
|
198
210
|
end
|
|
211
|
+
|
|
212
|
+
def collaboration_policy_config
|
|
213
|
+
@policy_resolver&.collaboration_config || {}
|
|
214
|
+
end
|
|
199
215
|
end
|
|
200
216
|
end
|
|
201
217
|
end
|
|
@@ -23,6 +23,7 @@ module Collavre
|
|
|
23
23
|
updated = Task.where(id: task.id, status: "queued").update_all(status: "pending")
|
|
24
24
|
if updated > 0
|
|
25
25
|
task.reload
|
|
26
|
+
cleanup_waiting_notices!(task)
|
|
26
27
|
refresh_deferred_context!(task)
|
|
27
28
|
|
|
28
29
|
if task.status == "cancelled"
|
|
@@ -35,6 +36,19 @@ module Collavre
|
|
|
35
36
|
end
|
|
36
37
|
end
|
|
37
38
|
|
|
39
|
+
# Remove waiting notice comments (system messages) for this task's creative/topic.
|
|
40
|
+
def self.cleanup_waiting_notices!(task)
|
|
41
|
+
context = task.trigger_event_payload
|
|
42
|
+
creative_id = context&.dig("creative", "id")
|
|
43
|
+
topic_id = context&.dig("topic", "id")
|
|
44
|
+
return unless creative_id
|
|
45
|
+
|
|
46
|
+
Comment.where(creative_id: creative_id, topic_id: topic_id, user_id: nil)
|
|
47
|
+
.where("content LIKE ?", "⏳%")
|
|
48
|
+
.destroy_all
|
|
49
|
+
end
|
|
50
|
+
private_class_method :cleanup_waiting_notices!
|
|
51
|
+
|
|
38
52
|
# Refresh trigger_event_payload so the deferred agent sees the latest
|
|
39
53
|
# conversation state instead of the stale snapshot from enqueue time.
|
|
40
54
|
# Skips AI agent's own comments to prevent self-response loops.
|
|
@@ -47,7 +61,7 @@ module Collavre
|
|
|
47
61
|
topic_id = context.dig("topic", "id")
|
|
48
62
|
scope = Comment
|
|
49
63
|
.where(creative_id: creative_id, topic_id: topic_id, private: false)
|
|
50
|
-
.where.not(user_id: task.agent_id)
|
|
64
|
+
.where.not(user_id: [ task.agent_id, nil ])
|
|
51
65
|
.order(created_at: :desc)
|
|
52
66
|
latest_comment = scope.first
|
|
53
67
|
|
|
@@ -110,33 +124,68 @@ module Collavre
|
|
|
110
124
|
|
|
111
125
|
def enqueue_jobs(decisions)
|
|
112
126
|
decisions.filter_map do |decision|
|
|
127
|
+
agent = decision[:agent]
|
|
128
|
+
log_decision(decision)
|
|
129
|
+
|
|
113
130
|
case decision[:timing]
|
|
114
131
|
when :immediate
|
|
115
|
-
AiAgentJob.perform_later(
|
|
116
|
-
|
|
132
|
+
AiAgentJob.perform_later(agent.id, @event_name, @context)
|
|
133
|
+
agent
|
|
117
134
|
when :deferred
|
|
118
135
|
Task.create!(
|
|
119
136
|
name: "Response to #{@event_name}",
|
|
120
137
|
status: "queued",
|
|
121
138
|
trigger_event_name: @event_name,
|
|
122
139
|
trigger_event_payload: @context,
|
|
123
|
-
agent:
|
|
140
|
+
agent: agent,
|
|
124
141
|
topic_id: @context.dig("topic", "id")
|
|
125
142
|
)
|
|
126
|
-
decision
|
|
143
|
+
post_waiting_notice(agent, decision)
|
|
144
|
+
agent
|
|
127
145
|
when :delayed
|
|
128
146
|
AiAgentJob.set(wait: decision[:delay]).perform_later(
|
|
129
|
-
|
|
147
|
+
agent.id, @event_name, @context
|
|
130
148
|
)
|
|
131
|
-
decision
|
|
149
|
+
post_waiting_notice(agent, decision)
|
|
150
|
+
agent
|
|
132
151
|
when :rejected
|
|
133
|
-
Rails.logger.info(
|
|
134
|
-
"[Orchestrator] Agent #{decision[:agent].id} rejected: #{decision[:reason]}"
|
|
135
|
-
)
|
|
136
152
|
nil
|
|
137
153
|
end
|
|
138
154
|
end
|
|
139
155
|
end
|
|
156
|
+
|
|
157
|
+
def log_decision(decision)
|
|
158
|
+
agent = decision[:agent]
|
|
159
|
+
topic_id = @context.dig("topic", "id") || "main"
|
|
160
|
+
detail = [ decision[:timing], decision[:reason] ].compact.join(": ")
|
|
161
|
+
detail += " #{decision[:delay]}s" if decision[:delay]
|
|
162
|
+
Rails.logger.info(
|
|
163
|
+
"[Orchestrator] Agent #{agent.id} (#{agent.name}) → #{detail} " \
|
|
164
|
+
"(event=#{@event_name}, topic=#{topic_id})"
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def post_waiting_notice(agent, decision)
|
|
169
|
+
creative_id = @context.dig("creative", "id")
|
|
170
|
+
topic_id = @context.dig("topic", "id")
|
|
171
|
+
return unless creative_id
|
|
172
|
+
|
|
173
|
+
creative = Creative.find_by(id: creative_id)
|
|
174
|
+
return unless creative
|
|
175
|
+
|
|
176
|
+
reason_key = decision[:reason] || :unknown
|
|
177
|
+
reason_text = I18n.t(
|
|
178
|
+
"collavre.orchestration.waiting_reasons.#{reason_key}",
|
|
179
|
+
default: reason_key.to_s.humanize
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
creative.comments.create!(
|
|
183
|
+
content: I18n.t("collavre.orchestration.waiting_notice", reason: reason_text),
|
|
184
|
+
topic_id: topic_id,
|
|
185
|
+
private: false,
|
|
186
|
+
skip_default_user: true
|
|
187
|
+
)
|
|
188
|
+
end
|
|
140
189
|
end
|
|
141
190
|
end
|
|
142
191
|
end
|
|
@@ -67,9 +67,12 @@ module Collavre
|
|
|
67
67
|
Rails.cache.write(key, interactions, expires_in: CACHE_EXPIRY)
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
# Record a task creation for creative retry detection
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
# Record a task creation for creative retry detection (per-topic)
|
|
71
|
+
# Skips recording for user-initiated messages (only agent-to-agent counts as potential loop)
|
|
72
|
+
def record_task(creative_id, agent_id, topic_id: nil, triggered_by_user: false)
|
|
73
|
+
return if triggered_by_user
|
|
74
|
+
|
|
75
|
+
key = topic_tasks_key(creative_id, topic_id)
|
|
73
76
|
tasks = Rails.cache.read(key) || []
|
|
74
77
|
tasks << { at: Time.current.to_i, agent_id: agent_id }
|
|
75
78
|
|
|
@@ -136,12 +139,13 @@ module Collavre
|
|
|
136
139
|
safe_result
|
|
137
140
|
end
|
|
138
141
|
|
|
139
|
-
# Detect too many tasks on same creative
|
|
142
|
+
# Detect too many tasks on same topic (per-topic, not per-creative)
|
|
140
143
|
def check_creative_retry
|
|
141
144
|
creative_id = @context.dig("creative", "id")
|
|
145
|
+
topic_id = @context.dig("topic", "id")
|
|
142
146
|
return safe_result unless creative_id
|
|
143
147
|
|
|
144
|
-
key =
|
|
148
|
+
key = topic_tasks_key(creative_id, topic_id)
|
|
145
149
|
tasks = Rails.cache.read(key) || []
|
|
146
150
|
|
|
147
151
|
# Filter to window
|
|
@@ -154,6 +158,7 @@ module Collavre
|
|
|
154
158
|
reason: :creative_retry_exceeded,
|
|
155
159
|
details: {
|
|
156
160
|
creative_id: creative_id,
|
|
161
|
+
topic_id: topic_id,
|
|
157
162
|
task_count: recent_tasks.size,
|
|
158
163
|
threshold: creative_retry_threshold,
|
|
159
164
|
window_minutes: @config["creative_retry_window_minutes"]
|
|
@@ -253,8 +258,8 @@ module Collavre
|
|
|
253
258
|
"#{CACHE_PREFIX}:ping_pong:#{sorted[0]}-#{sorted[1]}:#{creative_id}"
|
|
254
259
|
end
|
|
255
260
|
|
|
256
|
-
def
|
|
257
|
-
"#{CACHE_PREFIX}:creative:#{creative_id}:tasks"
|
|
261
|
+
def topic_tasks_key(creative_id, topic_id)
|
|
262
|
+
"#{CACHE_PREFIX}:creative:#{creative_id}:topic:#{topic_id || "main"}:tasks"
|
|
258
263
|
end
|
|
259
264
|
|
|
260
265
|
def token_usage_key(agent_id)
|
|
@@ -34,7 +34,7 @@ module Collavre
|
|
|
34
34
|
"max_retries" => 3,
|
|
35
35
|
"retry_delay_seconds" => 5,
|
|
36
36
|
# Loop breaker settings
|
|
37
|
-
"loop_breaker_enabled" =>
|
|
37
|
+
"loop_breaker_enabled" => true,
|
|
38
38
|
"ping_pong_threshold" => 5, # Max back-and-forth between same agents
|
|
39
39
|
"creative_retry_threshold" => 10, # Max tasks on same creative in time window
|
|
40
40
|
"creative_retry_window_minutes" => 30,
|
|
@@ -43,10 +43,19 @@ module Collavre
|
|
|
43
43
|
"token_spike_window_minutes" => 10
|
|
44
44
|
},
|
|
45
45
|
"stuck_detection" => {
|
|
46
|
-
"enabled" =>
|
|
46
|
+
"enabled" => false,
|
|
47
47
|
"task_stuck_threshold_minutes" => 30, # Task running for > N minutes
|
|
48
48
|
"creative_stall_threshold_minutes" => 120, # Creative no progress for > N minutes
|
|
49
49
|
"create_system_comment" => true # Create system comment on escalation
|
|
50
|
+
},
|
|
51
|
+
"collaboration" => {
|
|
52
|
+
"a2a_focus_instruction" => nil, # nil = locale default
|
|
53
|
+
"a2a_completion_instruction" => nil,
|
|
54
|
+
"a2a_followup_instruction" => nil,
|
|
55
|
+
"mention_rule" => nil,
|
|
56
|
+
"confidence_rule" => nil,
|
|
57
|
+
"escalation_rule" => nil,
|
|
58
|
+
"review_rule" => nil
|
|
50
59
|
}
|
|
51
60
|
}.freeze
|
|
52
61
|
|
|
@@ -135,6 +144,11 @@ module Collavre
|
|
|
135
144
|
}
|
|
136
145
|
end
|
|
137
146
|
|
|
147
|
+
# Collaboration config
|
|
148
|
+
def collaboration_config
|
|
149
|
+
@collaboration_config ||= merge_policies("collaboration")
|
|
150
|
+
end
|
|
151
|
+
|
|
138
152
|
# Stuck detection settings
|
|
139
153
|
def stuck_detection_enabled?
|
|
140
154
|
stuck_detection_config["enabled"] == true
|
|
@@ -77,7 +77,7 @@ module Collavre
|
|
|
77
77
|
if topic_max && @context.key?("topic")
|
|
78
78
|
topic_id = @context.dig("topic", "id")
|
|
79
79
|
if (Task.running_for_topic(topic_id).count + topic_immediate_count) >= topic_max
|
|
80
|
-
return deferred_decision(agent)
|
|
80
|
+
return deferred_decision(agent, :topic_concurrency)
|
|
81
81
|
end
|
|
82
82
|
end
|
|
83
83
|
|
|
@@ -101,10 +101,11 @@ module Collavre
|
|
|
101
101
|
}
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
-
def deferred_decision(agent)
|
|
104
|
+
def deferred_decision(agent, reason = nil)
|
|
105
105
|
{
|
|
106
106
|
agent: agent,
|
|
107
|
-
timing: :deferred
|
|
107
|
+
timing: :deferred,
|
|
108
|
+
reason: reason
|
|
108
109
|
}
|
|
109
110
|
end
|
|
110
111
|
|
|
@@ -267,7 +267,7 @@ module Collavre
|
|
|
267
267
|
[
|
|
268
268
|
"collavre.stuck_detection.creative_stalled",
|
|
269
269
|
{
|
|
270
|
-
creative_title: creative.
|
|
270
|
+
creative_title: creative.creative_snippet,
|
|
271
271
|
hours: hours_stuck,
|
|
272
272
|
progress: ((creative.progress || 0) * 100).round
|
|
273
273
|
}
|
|
@@ -55,12 +55,7 @@ module Collavre
|
|
|
55
55
|
content = chat_context["content"]
|
|
56
56
|
return nil unless content
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
match = content.match(/\A@([^:]+?):\s*/)
|
|
60
|
-
return nil unless match
|
|
61
|
-
|
|
62
|
-
name = match[1].strip
|
|
63
|
-
user = User.where("LOWER(name) = ?", name.downcase).first
|
|
58
|
+
user = MentionParser.resolve_user(content)
|
|
64
59
|
user&.as_json(only: [ :id, :name, :email ])
|
|
65
60
|
end
|
|
66
61
|
end
|