diff --git a/app/assets/javascripts/admin/sensemaker/scripts.js b/app/assets/javascripts/admin/sensemaker/scripts.js
new file mode 100644
index 00000000000..63bf87c8c75
--- /dev/null
+++ b/app/assets/javascripts/admin/sensemaker/scripts.js
@@ -0,0 +1,46 @@
+(function() {
+ "use strict";
+ App.AdminSensemakerScripts = {
+
+ initialize: function() {
+ var buttons = ".admin .sensemaker form button[type='submit'][data-remote='true']";
+ var inputs = ".admin .sensemaker form input[type='submit'][data-remote='true']";
+ var remoteSubmits = $(buttons + ", " + inputs);
+
+ remoteSubmits.on("click", function(event) {
+ event.preventDefault();
+ var form = $(this).closest("form");
+ App.AdminSensemakerScripts.remoteSubmit(form, $(this));
+ });
+
+ var forms = $(".admin .sensemaker form");
+ forms.on("submit", this.handleTempDisable);
+
+ var busySelector =
+ "button[type='submit'][data-temp-disable='true'], input[type='submit'][data-temp-disable='true']";
+ forms.on("click", busySelector, this.applyBusyState);
+ },
+
+ handleTempDisable: function() {
+ App.AdminSensemakerScripts.applyBusyState.call(this, { currentTarget: this });
+ },
+
+ applyBusyState: function(event) {
+ var form = $(event.currentTarget).closest("form");
+ form.addClass("sensemaker-buttons-busy");
+ },
+
+ remoteSubmit: function(form, submitter) {
+ var formData = form.serialize();
+ var formAction = submitter.attr("formaction") || form.attr("action");
+ var formMethod = form.attr("method") || "POST";
+
+ $.ajax({
+ url: formAction,
+ type: formMethod,
+ data: formData,
+ dataType: "script" // This tells Rails to expect JavaScript response
+ });
+ },
+ };
+}).call(this);
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index 13cee07b9dc..6e8dd5ceba1 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -172,6 +172,7 @@ var initialize_modules = function() {
App.AdminTenantsForm.initialize();
App.AdminVotationTypesFields.initialize();
App.AdminMenu.initialize();
+ App.AdminSensemakerScripts.initialize();
App.BudgetEditAssociations.initialize();
App.BudgetHideMoney.initialize();
App.Datepicker.initialize();
diff --git a/app/assets/stylesheets/admin/menu.scss b/app/assets/stylesheets/admin/menu.scss
index 0ad34fca3b4..1e70020da58 100644
--- a/app/assets/stylesheets/admin/menu.scss
+++ b/app/assets/stylesheets/admin/menu.scss
@@ -110,6 +110,10 @@
@include icon(brain, solid);
}
+ &.sensemaker-link {
+ @include icon(lightbulb, solid);
+ }
+
&.administrators-link {
@include icon(user, solid);
}
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 8a9be181e81..b6438c99fc2 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -27,6 +27,7 @@
@import "documents/**/*";
@import "layout/**/*";
@import "machine_learning/**/*";
+@import "sensemaker/**/*";
@import "management/**/*";
@import "milestones/**/*";
@import "moderation/**/*";
diff --git a/app/assets/stylesheets/sensemaker/sensemaker.scss b/app/assets/stylesheets/sensemaker/sensemaker.scss
new file mode 100644
index 00000000000..343512170e5
--- /dev/null
+++ b/app/assets/stylesheets/sensemaker/sensemaker.scss
@@ -0,0 +1,385 @@
+.admin .sensemaker {
+ .additional-context {
+ border: 1px solid $border;
+ height: fit-content;
+ padding: 0.2rem;
+
+ pre {
+ background-color: #efefef;
+ margin: 0;
+ max-height: 22.4rem;
+ overflow-wrap: break-word;
+ overflow-y: scroll;
+ padding: 0.2rem;
+ white-space: break-spaces;
+ }
+ }
+
+ form.sensemaker-buttons-busy {
+ button[type="submit"][data-temp-disable="true"],
+ input[type="submit"][data-temp-disable="true"] {
+ cursor: not-allowed;
+ opacity: 0.7;
+ pointer-events: none;
+ }
+ }
+
+ fieldset {
+ border: 1px solid $border;
+ border-radius: $button-radius;
+ padding: 1rem;
+ }
+
+ .no-margin { margin: 0; }
+ .margin-bottom-sm { margin-bottom: $line-height * 0.5; }
+ .margin-bottom-lg { margin-bottom: $line-height * 2; }
+ .margin-bottom-xl { margin-bottom: $line-height * 3; }
+
+ .primary.callout {
+ margin-bottom: $line-height * 1.5;
+ }
+
+ summary {
+ margin-bottom: 0.5rem;
+ }
+
+ .bold { font-weight: bold; }
+ .italic { font-style: italic; }
+
+ .tip {
+ @include has-fa-icon(info-circle, solid);
+ margin-right: 0.5rem;
+ }
+
+ h4 {
+ margin-bottom: 0.8rem;
+ }
+
+ .grid-two-column {
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: 1fr 1fr;
+
+ div {
+ width: 100%;
+ }
+ }
+
+ .meta {
+ color: #6d6d6d;
+ font-size: 90%;
+ }
+
+ .search-control {
+ summary { opacity: 0.8; }
+ summary:hover { opacity: 1; }
+ summary div { display: inline-block; }
+
+ .query-type-tab {
+ background: #efefef;
+ border: 1px solid $border;
+ border-radius: $button-radius;
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ opacity: 0.8;
+ padding: 1rem;
+ z-index: 2;
+ }
+
+ .query-type-tab.active {
+ border-bottom: none;
+ opacity: 1;
+ }
+
+ form {
+ background: #efefef;
+ border: 1px solid $border;
+ border-radius: $button-radius;
+ border-top: none;
+ padding: 1rem;
+ position: relative;
+ top: -2px;
+ z-index: 1;
+ }
+
+ .target-search-group {
+ border: 1px solid $border;
+ border-radius: 0.5rem;
+ display: flex;
+ flex-direction: row;
+ gap: 0;
+ margin-bottom: 1rem;
+ overflow: hidden;
+ padding: 0;
+
+ .select-wrapper {
+ position: relative;
+ }
+
+ .select-wrapper::after {
+ color: $white;
+ content: "▼";
+ font-size: 0.6rem;
+ position: absolute;
+ right: 0.5rem;
+ top: 50%;
+ transform: translateY(-50%);
+ }
+
+ select {
+ background: $brand;
+ border: 0;
+ border-radius: 0;
+ color: $white;
+ flex: 1;
+ margin: 0;
+ max-width: 12rem;
+ overflow: hidden;
+ padding-left: 1rem;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ select:focus {
+ box-shadow: none;
+ outline: none;
+ }
+
+ input[type="text"] {
+ border: 0;
+ box-shadow: none;
+ flex: 1;
+ margin: 0;
+ padding: 1rem;
+ }
+
+ input[type="text"]:focus {
+ border-radius: 0;
+ }
+
+ .button {
+ border-bottom-left-radius: 0;
+ border-top-left-radius: 0;
+ margin: 0;
+ }
+ }
+
+ .target-search-results {
+ background: #f6f6f6;
+ border: 1px solid $border;
+ border-radius: 0.5rem;
+ margin: 0;
+ max-height: 45vh;
+ overflow: hidden;
+ overflow-y: scroll;
+ padding: 0.5rem;
+
+ ul {
+ list-style: none;
+ }
+
+ // (moved to shallower block below)
+
+ li.result-group {
+ list-style: none;
+ margin-bottom: 0.5rem;
+ margin-top: 0.5rem;
+ padding: 0;
+ }
+
+ // (moved to shallower block below)
+
+ li.result-row {
+ opacity: 0.8;
+ padding: 0.1rem;
+ }
+
+ li.result-row:hover {
+ opacity: 1;
+ }
+
+ li.result-row:nth-child(even) {
+ background: transparent;
+ }
+
+ li.result-row:nth-child(odd) {
+ background: #f2f2f2;
+ }
+
+ li.result-row.selected {
+ opacity: 1;
+ }
+ }
+ }
+
+ .target-search-results {
+ .result-group > span {
+ font-weight: bold;
+ }
+ .result-group ul { margin-left: 1rem; }
+ .result-row.selected a { color: #000; }
+ .result-row.selected a:hover { text-decoration: none; }
+ }
+
+ .input-preview {
+ background: #efefef;
+ border: 1px solid $border;
+ border-radius: $button-radius;
+ color: #555;
+ font-size: $small-font-size;
+ max-height: 40vh;
+ overflow: hidden;
+ overflow-y: scroll;
+ padding: 6px;
+ text-wrap: auto;
+ }
+
+ .target-type-option {
+ margin: 0;
+ padding: 0;
+ }
+
+ .target-type-option input {
+ display: none;
+ }
+
+ .target-type-option span {
+ align-items: center;
+ background: $white;
+ border: 1px solid $border;
+ border-radius: 1rem;
+ cursor: pointer;
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ margin: 0;
+ padding: 0.2rem 0.6rem;
+ }
+
+ .target-type-option input:checked + span {
+ background: $brand;
+ border-color: transparent;
+ color: $white;
+ }
+
+ .job-row {
+ &.parent-job {
+ background-color: #f8f9fa;
+ }
+
+ &.child-job {
+ background-color: #fff;
+
+ td:first-child {
+ padding-left: 1rem;
+ }
+ }
+ }
+
+ .job-row:hover {
+ background-color: #f1f3f4;
+ }
+
+ .child-job:hover {
+ background-color: #f8f9fa;
+ }
+
+ // Job links styling
+ .job-link {
+ color: $brand;
+ font-weight: bold;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ // Job details styling
+ .job-details-table {
+ border-collapse: collapse;
+ width: 100%;
+
+ td {
+ border-bottom: 1px solid #e9ecef;
+ padding: 0.5rem;
+
+ &:first-child {
+ font-weight: bold;
+ width: 30%;
+ }
+ }
+ }
+
+ .job-status-indicator {
+ background-color: $brand;
+ border-radius: 100%;
+ display: inline-block;
+ height: 0.5rem;
+ margin-right: 0.1rem;
+ width: 0.5rem;
+
+ &.job-status-completed {
+ background-color: $color-success;
+ }
+
+ &.job-status-failed {
+ background-color: $color-alert;
+ }
+
+ &.job-status-cancelled {
+ background-color: $color-warning;
+ }
+
+ &.job-status-unstarted {
+ background-color: $color-info;
+ }
+ }
+}
+
+.sensemaker {
+ .flex-between {
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ gap: 2px;
+ justify-content: space-between;
+
+ .button { margin-bottom: 0; }
+ }
+
+ .flex-row {
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ gap: 2px;
+ justify-content: flex-start;
+ }
+
+ .flex-grow {
+ flex: 1;
+ }
+
+ .flex-row-end {
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ gap: 2px;
+ justify-content: flex-end;
+ }
+
+ section {
+ margin-bottom: 2rem;
+ }
+
+ .job-type-label {
+ background: $brand;
+ border-radius: 0.25rem;
+ color: $white;
+ font-size: 0.75rem;
+ font-weight: normal;
+ margin-left: 1rem;
+ padding: 0.25rem 0.5rem;
+ text-transform: uppercase;
+ }
+}
diff --git a/app/components/admin/menu_component.rb b/app/components/admin/menu_component.rb
index 8635043abc5..17c72229ae5 100644
--- a/app/components/admin/menu_component.rb
+++ b/app/components/admin/menu_component.rb
@@ -28,7 +28,8 @@ def default_links
stats_link,
settings_links,
dashboard_links,
- (machine_learning_link if ::MachineLearning.enabled?)
+ (machine_learning_link if ::MachineLearning.enabled?),
+ (sensemaker_link if feature?(:sensemaker))
]
end
@@ -566,6 +567,15 @@ def machine_learning_link
]
end
+ def sensemaker_link
+ [
+ t("admin.menu.sensemaker"),
+ admin_sensemaker_jobs_path,
+ controller_name == "jobs" && controller.class.module_parent == Admin::Sensemaker,
+ class: "sensemaker-link"
+ ]
+ end
+
def administrator_tasks_link
[
t("admin.menu.administrator_tasks"),
diff --git a/app/components/admin/sensemaker/help_component.html.erb b/app/components/admin/sensemaker/help_component.html.erb
new file mode 100644
index 00000000000..74677a095de
--- /dev/null
+++ b/app/components/admin/sensemaker/help_component.html.erb
@@ -0,0 +1,23 @@
+
+
+
<%= t("admin.sensemaker.help.title_1") %>
+
<%= t("admin.sensemaker.help.description_1") %>
+
+
<%= t("admin.sensemaker.help.title_2") %>
+
<%= t("admin.sensemaker.help.description_2") %>
+
+
<%= t("admin.sensemaker.help.title_3") %>
+
<%= t("admin.sensemaker.help.description_3") %>
+
+
<%= t("admin.sensemaker.help.title_4") %>
+
<%= t("admin.sensemaker.help.description_4") %>
+
+ <% Sensemaker::JobRunner::SCRIPTS.each do |script| %>
+ -
+ <%= t("admin.sensemaker.scripts.#{script.parameterize.underscore}.title") %> -
+ <%= t("admin.sensemaker.scripts.#{script.parameterize.underscore}.description") %>
+
+ <% end %>
+
+
+
diff --git a/app/components/admin/sensemaker/help_component.rb b/app/components/admin/sensemaker/help_component.rb
new file mode 100644
index 00000000000..44965f95e60
--- /dev/null
+++ b/app/components/admin/sensemaker/help_component.rb
@@ -0,0 +1,2 @@
+class Admin::Sensemaker::HelpComponent < ApplicationComponent
+end
diff --git a/app/components/admin/sensemaker/index_component.html.erb b/app/components/admin/sensemaker/index_component.html.erb
new file mode 100644
index 00000000000..04cdb675e82
--- /dev/null
+++ b/app/components/admin/sensemaker/index_component.html.erb
@@ -0,0 +1,85 @@
+<%= header %>
+
+
+ <% if enabled? %>
+
+ <%= sanitize(t("admin.sensemaker.help_text")) %>
+
+
+
+
+
+
+
+ <% if running_jobs.any? %>
+
+ <%= t("admin.sensemaker.index.currently_running") %>
+ <%= button_to t("admin.sensemaker.index.cancel_all"), cancel_admin_sensemaker_jobs_path, class: "button", method: :delete, data: { confirm: t("admin.sensemaker.cancel_alert") } %>
+
+
+
+
+
+ | <%= t("admin.sensemaker.index.table_item") %> |
+ <%= t("admin.sensemaker.index.table_script") %> |
+ <%= t("admin.sensemaker.index.table_started_at") %> |
+
+
+
+ <% running_jobs.each do |sensemaker_job| %>
+ <%= render Admin::Sensemaker::JobRowComponent.new(sensemaker_job) %>
+ <% end %>
+
+
+
+
+ <% end %>
+
+
+ <%= t("admin.sensemaker.index.past_runs") %>
+ <%= link_to t("admin.sensemaker.index.new_run"), new_admin_sensemaker_job_path, class: "button" %>
+
+
+
+
+
+ | <%= t("admin.sensemaker.index.table_item") %> |
+ <%= t("admin.sensemaker.index.table_script") %> |
+ <%= t("admin.sensemaker.index.table_status") %> |
+ <%= t("admin.sensemaker.index.table_actions") %> |
+
+
+
+ <% sensemaker_jobs.each do |sensemaker_job| %>
+ <%= render Admin::Sensemaker::JobRowComponent.new(sensemaker_job) %>
+ <% end %>
+
+
+
+
+
+ <%= render Admin::Sensemaker::HelpComponent.new %>
+
+
+
+ <% else %>
+
+
+ <%= sanitize(t("admin.sensemaker.feature_disabled",
+ link: link_to(t("admin.sensemaker.feature_disabled_link"),
+ admin_settings_path(anchor: "tab-feature-flags")))) %>
+
+
+ <% end %>
+
diff --git a/app/components/admin/sensemaker/index_component.rb b/app/components/admin/sensemaker/index_component.rb
new file mode 100644
index 00000000000..79534dce4f8
--- /dev/null
+++ b/app/components/admin/sensemaker/index_component.rb
@@ -0,0 +1,18 @@
+class Admin::Sensemaker::IndexComponent < ApplicationComponent
+ include Header
+
+ attr_reader :sensemaker_jobs, :running_jobs
+
+ def initialize(sensemaker_jobs, running_jobs)
+ @sensemaker_jobs = sensemaker_jobs
+ @running_jobs = running_jobs
+ end
+
+ def title
+ t("admin.sensemaker.index.title")
+ end
+
+ def enabled?
+ feature?(:sensemaker)
+ end
+end
diff --git a/app/components/admin/sensemaker/job_component_helpers.rb b/app/components/admin/sensemaker/job_component_helpers.rb
new file mode 100644
index 00000000000..b07707dedcc
--- /dev/null
+++ b/app/components/admin/sensemaker/job_component_helpers.rb
@@ -0,0 +1,57 @@
+module Admin::Sensemaker::JobComponentHelpers
+ extend ActiveSupport::Concern
+
+ def job_status_class
+ "job-status-#{job.status.downcase}"
+ end
+
+ def analysable_title
+ if job.analysable.present?
+ job.conversation.target_label(format: :short)
+ elsif job.analysable_type == "Proposal" && job.analysable_id.nil?
+ I18n.t("admin.sensemaker.job_show.analysable_all_proposals")
+ else
+ I18n.t("admin.sensemaker.job_show.analysable_deleted")
+ end
+ end
+
+ def has_error?
+ job.error.present? && job.status.eql?("Failed")
+ end
+
+ def can_download?
+ job.finished? && !job.errored?
+ end
+
+ def can_publish?
+ job.publishable?
+ end
+
+ def is_published?
+ job.published?
+ end
+
+ def status_text
+ time_format = "%Y-%m-%d %H:%M"
+ case job.status
+ when "Completed"
+ I18n.t("admin.sensemaker.job_show.status_completed_at", time: job.finished_at.strftime(time_format))
+ when "Failed"
+ I18n.t("admin.sensemaker.job_show.status_failed_at", time: job.finished_at.strftime(time_format))
+ when "Running"
+ I18n.t("admin.sensemaker.job_show.status_started_at", time: job.started_at.strftime(time_format))
+ when "Cancelled"
+ I18n.t("admin.sensemaker.job_show.status_cancelled_at", time: job.finished_at.strftime(time_format))
+ else
+ I18n.t("admin.sensemaker.job_show.status_created_at", time: job.created_at.strftime(time_format))
+ end
+ end
+
+ def parent_job?
+ job.parent_job_id.blank?
+ end
+
+ def parent_job
+ @parent_job ||= job.parent_job if job.parent_job_id.present?
+ end
+end
diff --git a/app/components/admin/sensemaker/job_row_component.html.erb b/app/components/admin/sensemaker/job_row_component.html.erb
new file mode 100644
index 00000000000..9dc1ddca293
--- /dev/null
+++ b/app/components/admin/sensemaker/job_row_component.html.erb
@@ -0,0 +1,27 @@
+
+ |
+ <%= link_to t("admin.sensemaker.job_row.job_id_link", id: job.id), admin_sensemaker_job_path(job), class: "job-link" %>
+ <%= content_tag :p, analysable_title, style: "" %>
+ |
+ <%= t("admin.sensemaker.scripts.#{job.script.parameterize.underscore}.title") %> |
+ <% if job.running? %>
+ <%= job.started_at.strftime("%Y-%m-%d %H:%M") %> |
+ <% else %>
+
+ <%= content_tag :span, "", class: "job-status-indicator #{job_status_class}" %>
+ <%= content_tag :span, status_text, class: "meta" %>
+ |
+
+
+ <% if can_publish? %>
+ <% if is_published? %>
+ <%= button_to t("admin.sensemaker.job_row.unpublish"), unpublish_admin_sensemaker_job_path(job), class: "button small inline-block", method: :patch %>
+ <% else %>
+ <%= button_to t("admin.sensemaker.job_row.publish"), publish_admin_sensemaker_job_path(job), class: "button small success inline-block", method: :patch %>
+ <% end %>
+ <% end %>
+ <%= link_to t("admin.actions.delete"), admin_sensemaker_job_path(job), class: "button error small inline-block", method: :delete, data: { confirm: t("admin.sensemaker.delete_alert") } %>
+
+ |
+ <% end %>
+
diff --git a/app/components/admin/sensemaker/job_row_component.rb b/app/components/admin/sensemaker/job_row_component.rb
new file mode 100644
index 00000000000..eb4fd1239d6
--- /dev/null
+++ b/app/components/admin/sensemaker/job_row_component.rb
@@ -0,0 +1,15 @@
+class Admin::Sensemaker::JobRowComponent < ApplicationComponent
+ include Admin::Sensemaker::JobComponentHelpers
+
+ attr_reader :job
+
+ def initialize(job)
+ @job = job
+ end
+
+ def css_classes
+ classes = ["job-row"]
+ classes << (parent_job? ? "parent-job" : "child-job")
+ classes.join(" ")
+ end
+end
diff --git a/app/components/admin/sensemaker/job_show_component.html.erb b/app/components/admin/sensemaker/job_show_component.html.erb
new file mode 100644
index 00000000000..f0a2994c583
--- /dev/null
+++ b/app/components/admin/sensemaker/job_show_component.html.erb
@@ -0,0 +1,170 @@
+<%= header %>
+
+
+ <% if enabled? %>
+
+ <%= sanitize(t("admin.sensemaker.help_text")) %>
+
+
+
+
+
<%= t("admin.sensemaker.job_show.details") %>
+
+ <%= link_to t("admin.sensemaker.job_show.back_to_jobs"), admin_sensemaker_jobs_path, class: "margin-right" %>
+ <% if can_publish? %>
+ <% if is_published? %>
+ <%= button_to t("admin.sensemaker.job_row.unpublish"), unpublish_admin_sensemaker_job_path(sensemaker_job), form_class: "inline", class: "button", method: :patch %>
+ <% else %>
+ <%= button_to t("admin.sensemaker.job_row.publish"), publish_admin_sensemaker_job_path(sensemaker_job), form_class: "inline", class: "button success", method: :patch %>
+ <% end %>
+ <% end %>
+ <%= link_to t("admin.actions.delete"), admin_sensemaker_job_path(sensemaker_job), class: "button error", method: :delete, data: { confirm: t("admin.sensemaker.delete_alert") } %>
+
+
+
+
+
+
+
+ | <%= t("admin.sensemaker.job_show.job_id") %> |
+ #<%= sensemaker_job.id %> |
+
+
+ | <%= t("admin.sensemaker.job_show.script") %> |
+ <%= sensemaker_job.script %> |
+
+
+ | <%= t("admin.sensemaker.job_show.status") %> |
+
+
+ <%= sensemaker_job.status %>
+ |
+
+ <% if sensemaker_job.comments_analysed.present? && sensemaker_job.comments_analysed > 0 %>
+
+ | <%= t("admin.sensemaker.job_show.comments_analysed") %> |
+ <%= sensemaker_job.comments_analysed %> |
+
+ <% end %>
+
+ | <%= t("admin.sensemaker.job_show.created") %> |
+ <%= sensemaker_job.created_at.strftime("%Y-%m-%d %H:%M") %> |
+
+ <% if sensemaker_job.started_at.present? %>
+
+ | <%= t("admin.sensemaker.job_show.started") %> |
+ <%= sensemaker_job.started_at.strftime("%Y-%m-%d %H:%M") %> |
+
+ <% end %>
+ <% if sensemaker_job.finished_at.present? %>
+
+ | <%= t("admin.sensemaker.job_show.finished") %> |
+ <%= sensemaker_job.finished_at.strftime("%Y-%m-%d %H:%M") %> |
+
+ <% end %>
+ <% unless parent_job? %>
+
+ | <%= t("admin.sensemaker.job_show.parent_job") %> |
+
+ <%= t("admin.sensemaker.job_show.job_spawned_by") %> <%= link_to t("admin.sensemaker.job_row.job_id_link", id: parent_job.id), admin_sensemaker_job_path(parent_job) %>
+ |
+
+ <% end %>
+
+
+ | <%= t("admin.sensemaker.job_show.target_type") %> |
+ <%= sensemaker_job.analysable_type %> |
+
+
+ | <%= t("admin.sensemaker.job_show.target_title") %> |
+ <%= analysable_title %> |
+
+
+
+
+
+
<%= t("admin.sensemaker.job_show.additional_context") %>
+
<%= sensemaker_job.additional_context %>
+
+
+
+
+ <% if has_error? %>
+
+ <%= t("admin.sensemaker.job_show.error_report") %>
+
+
+ <%= t("admin.sensemaker.job_show.error_log") %>
+ <%= sensemaker_job.error %>
+
+
+
+ <% end %>
+
+ <% if has_children? %>
+
+ <%= t("admin.sensemaker.job_show.prerequisite_jobs") %>
+ <%= t("admin.sensemaker.job_show.prerequisite_jobs_help") %>
+
+
+
+
+ | <%= t("admin.sensemaker.job_show.job_id") %> |
+ <%= t("admin.sensemaker.job_show.script") %> |
+ <%= t("admin.sensemaker.job_show.status") %> |
+ <%= t("admin.sensemaker.job_show.actions") %> |
+
+
+
+ <% child_jobs.each do |child_job| %>
+ <%= render Admin::Sensemaker::JobRowComponent.new(child_job) %>
+ <% end %>
+
+
+
+ <% end %>
+
+ <% if sensemaker_job.finished? %>
+
+ <%= t("admin.sensemaker.job_show.files") %>
+ <%= t("admin.sensemaker.job_show.select_file_download") %>
+
+ <%= t("admin.sensemaker.job_show.input_files") %>
+
+ <% input_paths = sensemaker_job.existing_input_artefact_paths %>
+ <% if input_paths.any? %>
+ <% input_paths.each do |path| %>
+ -
+ <%= link_to ::File.basename(path), download_admin_sensemaker_job_path(sensemaker_job, artefact: ::File.basename(path)) %>
+
+ <% end %>
+ <% else %>
+ - <%= t("admin.sensemaker.job_show.none_available") %>
+ <% end %>
+
+
+ <%= t("admin.sensemaker.job_show.output_files") %>
+
+ <% output_paths = sensemaker_job.existing_output_artefact_paths %>
+ <% if output_paths.any? %>
+ <% output_paths.each do |path| %>
+ -
+ <%= link_to ::File.basename(path), download_admin_sensemaker_job_path(sensemaker_job, artefact: ::File.basename(path)) %>
+
+ <% end %>
+ <% else %>
+ - <%= t("admin.sensemaker.job_show.none_available") %>
+ <% end %>
+
+
+ <% end %>
+ <% else %>
+
+
+ <%= sanitize(t("admin.sensemaker.feature_disabled",
+ link: link_to(t("admin.sensemaker.feature_disabled_link"),
+ admin_settings_path(anchor: "tab-feature-flags")))) %>
+
+
+ <% end %>
+
diff --git a/app/components/admin/sensemaker/job_show_component.rb b/app/components/admin/sensemaker/job_show_component.rb
new file mode 100644
index 00000000000..6ab564d35cc
--- /dev/null
+++ b/app/components/admin/sensemaker/job_show_component.rb
@@ -0,0 +1,27 @@
+class Admin::Sensemaker::JobShowComponent < ApplicationComponent
+ include Admin::Sensemaker::JobComponentHelpers
+ include Header
+
+ attr_reader :sensemaker_job, :child_jobs
+
+ def initialize(sensemaker_job, child_jobs)
+ @sensemaker_job = sensemaker_job
+ @child_jobs = child_jobs
+ end
+
+ def job
+ sensemaker_job
+ end
+
+ def title
+ "Sensemaker Job ##{sensemaker_job.id}"
+ end
+
+ def enabled?
+ feature?(:sensemaker)
+ end
+
+ def has_children?
+ child_jobs.any?
+ end
+end
diff --git a/app/components/admin/sensemaker/new_component.html.erb b/app/components/admin/sensemaker/new_component.html.erb
new file mode 100644
index 00000000000..8230d3f0e1c
--- /dev/null
+++ b/app/components/admin/sensemaker/new_component.html.erb
@@ -0,0 +1,95 @@
+<%= header %>
+
+<% selected_query_type = params[:query_type] || "Legislation::Process" %>
+
+
+
+
+ <% @query_types.each do |type| %>
+ <% selected = (selected_query_type == type) %>
+ <%= link_to new_admin_sensemaker_job_path(query_type: type, query: params[:query]), class: "query-type-tab #{"active" if selected}" do %>
+ <%= I18n.t("activerecord.models.#{type.underscore}.other") %>
+ <% end %>
+ <% end %>
+
+
+ <%= form_tag new_admin_sensemaker_job_path, class: "", method: :get do %>
+ <% conversation = @sensemaker_job.conversation if @sensemaker_job.analysable_type.present? %>
+ <% unless conversation&.target.present? %>
+
<%= t("admin.sensemaker.new.select_target") %> <%= link_to t("admin.sensemaker.new.or_analyse_all_proposals"), new_admin_sensemaker_job_path(target_type: "Proposal", target_id: nil), class: "bold" if selected_query_type.eql?("Proposal") %>.
+ <% end %>
+
+
+ <%= hidden_field_tag :query_type, selected_query_type %>
+ <% model_label = I18n.t("activerecord.models.#{(selected_query_type || "Legislation::Process").underscore}.other", default: "targets") %>
+ <%= text_field_tag :query, params[:query], placeholder: t("admin.sensemaker.new.search_placeholder", model_label: model_label), autocomplete: "off" %>
+ <%= submit_tag t("admin.shared.search.search"), class: "success button" %>
+
+
+
open<% end %>>
+
+ <%= @result_count %> <%= t("admin.sensemaker.new.results") %>
+
+
+ <% if @search_results.blank? %>
+ <%= content_tag :span, t("admin.shared.no_search_results"), class: "small italic" %>
+
+ <%= link_to t("admin.sensemaker.new.clear_search"), new_admin_sensemaker_job_path(query_type: selected_query_type), class: "small italic" %>
+ <% end %>
+
+ <% @search_results.each do |result_group| %>
+ <%= render Admin::Sensemaker::ResultComponent.new(
+ result_group: result_group,
+ selected_query_type: selected_query_type
+ ) %>
+ <% end %>
+
+
+ <% end %>
+
+
+ <% conversation = @sensemaker_job.conversation if @sensemaker_job.analysable_type.present? %>
+ <% if conversation&.target.present? || (conversation&.target.is_a?(Class) && conversation.target == Proposal) %>
+
+ <%= form_for @sensemaker_job, url: admin_sensemaker_jobs_path, method: :post do |f| %>
+ <%= f.hidden_field :analysable_type, label: false, required: true %>
+ <%= f.hidden_field :analysable_id, label: false, required: false %>
+
+
<%= t("admin.sensemaker.new.analysing") %> <%= conversation.target_label(format: :short) %>
+
<%= t("admin.sensemaker.new.review_context") %>
+
+ <%= f.text_area :additional_context,
+ label: t("admin.sensemaker.new.additional_context_label"),
+ hint: t("admin.sensemaker.new.additional_context_hint"),
+ rows: 10 %>
+
+
+ <%= t("admin.sensemaker.new.advanced_options") %>
+ <%= t("admin.sensemaker.new.expert_options_hint") %>
+ <% script_options = Sensemaker::JobRunner::SCRIPTS.map { |script| [I18n.t("admin.sensemaker.scripts.#{script.parameterize.underscore}.title"), script] } %>
+
+ <%= f.select :script, script_options, { include_blank: t("admin.sensemaker.new.select_script_blank"), required: false, label: false }, class: "no-margin" %>
+ <%= f.submit t("admin.sensemaker.new.run"), data: { temp_disable: true } %>
+
+
+
+
+ <%= link_to t("admin.sensemaker.new.cancel"), admin_sensemaker_jobs_path, class: "" %>
+
+
+ <%= f.submit t("admin.sensemaker.new.download_input_csv"),
+ type: "submit",
+ formaction: preview_admin_sensemaker_jobs_path(format: :csv),
+ class: "button", data: { temp_disable: true } %>
+ <%= button_tag t("admin.sensemaker.new.generate_summary"),
+ type: "submit", name: "quick_action", value: "summary",
+ class: "button success", data: { temp_disable: true } %>
+ <%= button_tag t("admin.sensemaker.new.generate_report"),
+ type: "submit", name: "quick_action", value: "report",
+ class: "button success", data: { temp_disable: true } %>
+
+
+
+ <% end %>
+ <% end %>
+
diff --git a/app/components/admin/sensemaker/new_component.rb b/app/components/admin/sensemaker/new_component.rb
new file mode 100644
index 00000000000..18a2fad579d
--- /dev/null
+++ b/app/components/admin/sensemaker/new_component.rb
@@ -0,0 +1,22 @@
+class Admin::Sensemaker::NewComponent < ApplicationComponent
+ include Header
+
+ attr_reader :sensemaker_job
+
+ def initialize(sensemaker_job, search_results, result_count)
+ @sensemaker_job = sensemaker_job
+ @search_results = search_results
+ @result_count = result_count
+ @query_types = [
+ "Debate",
+ "Proposal",
+ "Poll",
+ "Legislation::Process",
+ "Budget"
+ ]
+ end
+
+ def title
+ t("admin.sensemaker.new.title")
+ end
+end
diff --git a/app/components/admin/sensemaker/result_component.html.erb b/app/components/admin/sensemaker/result_component.html.erb
new file mode 100644
index 00000000000..830c5c26298
--- /dev/null
+++ b/app/components/admin/sensemaker/result_component.html.erb
@@ -0,0 +1,47 @@
+
+
+
+ <%= content_tag :span, @result_group[:title] %>
+
+
+
+
diff --git a/app/components/admin/sensemaker/result_component.rb b/app/components/admin/sensemaker/result_component.rb
new file mode 100644
index 00000000000..1258eef4e87
--- /dev/null
+++ b/app/components/admin/sensemaker/result_component.rb
@@ -0,0 +1,10 @@
+class Admin::Sensemaker::ResultComponent < ViewComponent::Base
+ def initialize(result_group:, selected_query_type:)
+ @result_group = result_group
+ @selected_query_type = selected_query_type
+ end
+
+ def render?
+ @result_group[:collection].present? && @result_group[:collection].any?
+ end
+end
diff --git a/app/controllers/admin/sensemaker/jobs_controller.rb b/app/controllers/admin/sensemaker/jobs_controller.rb
new file mode 100644
index 00000000000..7ef0719e10f
--- /dev/null
+++ b/app/controllers/admin/sensemaker/jobs_controller.rb
@@ -0,0 +1,292 @@
+class Admin::Sensemaker::JobsController < Admin::BaseController
+ def index
+ @running_jobs = Sensemaker::Job.running.includes(:children).order(created_at: :desc)
+ @sensemaker_jobs = Sensemaker::Job.where(parent_job_id: nil)
+ .includes(:children)
+ .where.not(id: @running_jobs.pluck(:id))
+ .order(created_at: :desc)
+ end
+
+ def show
+ @sensemaker_job = Sensemaker::Job.find(params[:id])
+ @child_jobs = @sensemaker_job.children.includes(:analysable).order(:created_at)
+ end
+
+ def new
+ @sensemaker_job = Sensemaker::Job.new
+
+ if params[:target_type].present?
+ @sensemaker_job.analysable_type = params[:target_type]
+ @sensemaker_job.analysable_id = params[:target_id] if params[:target_id].present?
+ if @sensemaker_job.analysable_type.present?
+ conversation = @sensemaker_job.conversation
+ @sensemaker_job.additional_context = conversation.compile_context if conversation.target.present?
+ end
+ end
+
+ target_query = params.fetch(:query, nil)
+ query_type = params.fetch(:query_type, "Legislation::Process")
+ limit = 20
+
+ @search_results = []
+ @result_count = 0
+
+ case query_type
+ when "Legislation::Process"
+ scope = Legislation::Process.includes(:questions, :proposals)
+ if target_query.present?
+ processes = scope.search(target_query)
+ else
+ processes = scope.order(created_at: :desc).limit(limit)
+ end
+
+ processes.each do |process|
+ collection = []
+
+ unless process.proposals.empty?
+ collection << {
+ title: I18n.t("activerecord.models.legislation/proposal.other"),
+ collection: process.proposals.map { |p| { title: result_title_for(p), object: p } }
+ }
+ @result_count += process.proposals.count
+ end
+
+ unless process.questions.empty?
+ question_collection = []
+
+ process.questions.each do |q|
+ @result_count += 1
+ question_collection << { title: result_title_for(q), object: q }
+ question_options = q.question_options.map { |qo| { title: result_title_for(qo), object: qo } }
+ @result_count += question_options.size
+ question_collection << { title: I18n.t("admin.sensemaker.new.segment_by_option"),
+ collection: question_options } unless question_options.empty?
+ end
+
+ collection << { title: I18n.t("activerecord.models.legislation/question.other"),
+ collection: question_collection } unless question_collection.empty?
+ end
+
+ @search_results << {
+ title: process.title,
+ collection: collection
+ }
+ end
+ when "Budget"
+ scope = Budget.published.with_translations(Globalize.fallbacks(I18n.locale))
+ if target_query.present?
+ budgets = scope.where("budget_translations.name ILIKE ?", "%#{target_query}%")
+ else
+ budgets = scope.order(created_at: :desc).limit(limit)
+ end
+
+ collection = []
+ budgets.each do |budget|
+ collection << { title: result_title_for(budget), object: budget }
+ @result_count += 1
+
+ group_entries = budget.groups.includes(:translations).map do |group|
+ { title: result_title_for(group), object: group }
+ end
+ unless group_entries.empty?
+ @result_count += group_entries.size
+ collection << { title: I18n.t("admin.sensemaker.new.groups"), collection: group_entries }
+ end
+ end
+
+ @search_results << {
+ title: I18n.t("activerecord.models.budget.other"),
+ collection: collection
+ }
+ when "Poll"
+ scope = Poll.not_budget.includes(questions: :question_options)
+ if target_query.present?
+ polls = scope.search(target_query)
+ else
+ polls = scope.order(created_at: :desc).limit(limit)
+ end
+
+ collection = []
+ polls.each do |poll|
+ collection << { title: result_title_for(poll), object: poll }
+ @result_count += 1
+
+ question_entries = poll.questions.map do |q|
+ @result_count += 1
+ entry = { title: result_title_for(q), object: q }
+ entry = entry.merge({
+ disabled: I18n.t("admin.sensemaker.new.no_free_text_to_analyse")
+ }) unless q.open?
+ entry
+ end
+ unless question_entries.empty?
+ collection << { title: I18n.t("activerecord.models.legislation/question.other"),
+ collection: question_entries }
+ end
+ end
+
+ @search_results << {
+ title: I18n.t("activerecord.models.poll.other"),
+ collection: collection
+ }
+ else
+ if target_query.present?
+ results = query_type.constantize.search(target_query)
+ else
+ results = query_type.constantize.order(created_at: :desc).limit(limit)
+ end
+
+ unless results.empty?
+ @search_results = [{
+ title: I18n.t("activerecord.models.#{query_type.underscore}.other"),
+ collection: results.map { |obj| { title: result_title_for(obj), object: obj } }
+ }]
+ @result_count += results.size
+ end
+ end
+ end
+
+ def create
+ valid_params = sensemaker_job_params.to_h
+
+ if params[:quick_action].in?(%w[summary report])
+ valid_params[:script] = params[:quick_action] == "summary" ? "runner.ts" : "sensemaking-report-ui"
+ elsif valid_params[:script].blank?
+ return redirect_to(
+ new_admin_sensemaker_job_path(
+ target_type: valid_params[:analysable_type],
+ target_id: valid_params[:analysable_id]
+ ),
+ alert: I18n.t("admin.sensemaker.notice.script_required")
+ )
+ end
+
+ valid_params.merge!(user: current_user, started_at: Time.current)
+ @sensemaker_job = Sensemaker::Job.create!(valid_params)
+
+ if Rails.env.test?
+ Sensemaker::JobRunner.new(@sensemaker_job).run_synchronously
+ else
+ Sensemaker::JobRunner.new(@sensemaker_job).run
+ end
+
+ redirect_to admin_sensemaker_jobs_path,
+ notice: I18n.t("admin.sensemaker.notice.script_info")
+ end
+
+ def preview
+ valid_params = sensemaker_job_params.to_h
+ valid_params.merge!(user: current_user)
+ sensemaker_job = Sensemaker::Job.new(valid_params)
+
+ @result = ""; csv_result = ""
+ status = 200
+ begin
+ conversation = sensemaker_job.conversation
+ target_persisted = conversation.target.is_a?(Class) ||
+ (conversation.target.present? && conversation.target.persisted?)
+ raise ActiveRecord::RecordNotFound unless target_persisted
+
+ @result += "---------Additional context---------\n\n"
+ @result += conversation.compile_context
+ @result += "\n\n---------Input CSV--------\n\n"
+ csv_result = Sensemaker::CsvExporter.new(conversation).export_to_string
+ @result += csv_result
+
+ filename = conversation.target_filename_label.parameterize
+ rescue ActiveRecord::RecordNotFound
+ @result += "Error: Target not found"
+ status = 404
+ rescue Exception => e
+ Rails.logger.error "Error: #{e.message}"
+ @result += "Error: #{e.message}"
+ status = 500
+ end
+
+ respond_to do |format|
+ format.html { render plain: @result, layout: false, status: status }
+ format.js
+ format.csv { send_data csv_result, filename: "#{filename}-input.csv", status: status }
+ end
+ end
+
+ def destroy
+ @sensemaker_job = Sensemaker::Job.find(params[:id])
+ @sensemaker_job.destroy!
+
+ redirect_to admin_sensemaker_jobs_path,
+ notice: I18n.t("admin.sensemaker.notice.deleted_job")
+ end
+
+ def download
+ @sensemaker_job = Sensemaker::Job.find(params[:id])
+ artefacts = @sensemaker_job.existing_input_artefact_paths + @sensemaker_job.existing_output_artefact_paths
+
+ if params[:artefact].present?
+ requested = File.join(Sensemaker::Paths.sensemaker_data_folder, params[:artefact])
+ if artefacts.include?(requested)
+ return send_file requested, filename: File.basename(requested)
+ else
+ return redirect_to admin_sensemaker_job_path(@sensemaker_job),
+ alert: I18n.t("admin.sensemaker.notice.output_file_not_found")
+ end
+ end
+
+ if @sensemaker_job.persisted_output.present? && File.exist?(@sensemaker_job.persisted_output)
+ return send_file @sensemaker_job.persisted_output,
+ filename: File.basename(@sensemaker_job.persisted_output)
+ end
+
+ redirect_to admin_sensemaker_jobs_path,
+ alert: I18n.t("admin.sensemaker.notice.output_file_not_found")
+ end
+
+ def cancel
+ Delayed::Job.where(queue: "sensemaker").destroy_all
+ running_jobs = Sensemaker::Job.running.all
+ running_jobs.each(&:cancel!)
+
+ redirect_to admin_sensemaker_jobs_path,
+ notice: I18n.t("admin.sensemaker.notice.cancelled_jobs")
+ end
+
+ def publish
+ @sensemaker_job = Sensemaker::Job.find(params[:id])
+
+ if @sensemaker_job.update(published: true)
+ redirect_to admin_sensemaker_job_path(@sensemaker_job),
+ notice: I18n.t("admin.sensemaker.notice.published")
+ else
+ redirect_to admin_sensemaker_job_path(@sensemaker_job),
+ alert: I18n.t("admin.sensemaker.notice.cannot_publish")
+ end
+ end
+
+ def unpublish
+ @sensemaker_job = Sensemaker::Job.find(params[:id])
+ @sensemaker_job.update!(published: false)
+ redirect_to admin_sensemaker_job_path(@sensemaker_job),
+ notice: I18n.t("admin.sensemaker.notice.unpublished")
+ end
+
+ def help
+ end
+
+ private
+
+ def sensemaker_job_params
+ params.require(:sensemaker_job).permit(:analysable_type, :analysable_id, :script, :additional_context)
+ end
+
+ def result_title_for(obj)
+ if obj.respond_to?(:title) && obj.title.present?
+ "##{obj.id} #{obj.title}"
+ elsif obj.respond_to?(:name) && obj.name.present?
+ "##{obj.id} #{obj.name}"
+ elsif obj.respond_to?(:value) && obj.value.present?
+ "##{obj.id} #{obj.value}"
+ else
+ "##{obj.id} #{obj}"
+ end
+ end
+end
diff --git a/app/models/abilities/administrator.rb b/app/models/abilities/administrator.rb
index ef00b0298a0..a6a6c7f357f 100644
--- a/app/models/abilities/administrator.rb
+++ b/app/models/abilities/administrator.rb
@@ -145,6 +145,7 @@ def initialize(user)
can [:create, :read], LocalCensusRecords::Import
can :manage, Cookies::Vendor
+ can [:manage, :publish, :unpublish], Sensemaker::Job
if Rails.application.config.multitenancy && Tenant.default?
can [:create, :read, :update, :hide, :restore], Tenant
diff --git a/app/models/abilities/everyone.rb b/app/models/abilities/everyone.rb
index fc77948bc9b..2374fcdd31f 100644
--- a/app/models/abilities/everyone.rb
+++ b/app/models/abilities/everyone.rb
@@ -26,7 +26,7 @@ def initialize(user)
can [:read], Legislation::Question
can [:read, :share], Legislation::Proposal
can [:search, :comments, :read, :create, :new_comment], Legislation::Annotation
-
+ can :read, Sensemaker::Job, published: true
can [:read, :help], ::SDG::Goal
can :read, ::SDG::Phase
end
diff --git a/app/models/sensemaker/job.rb b/app/models/sensemaker/job.rb
index b8a8155c7c4..c7fbc13c0f8 100644
--- a/app/models/sensemaker/job.rb
+++ b/app/models/sensemaker/job.rb
@@ -14,6 +14,11 @@ class Job < ApplicationRecord
"Budget::Group"
].freeze
+ PUBLISHABLE_SCRIPTS = [
+ "sensemaking-report-ui",
+ "runner.ts"
+ ].freeze
+
validates :analysable_type, inclusion: { in: ANALYSABLE_TYPES }
belongs_to :user, optional: false
@@ -23,6 +28,7 @@ class Job < ApplicationRecord
validates :analysable_type, presence: true
validates :analysable_id, presence: true, unless: -> { analysable_type == "Proposal" }
+ validate :publishing_is_allowed
belongs_to :analysable, polymorphic: true, optional: true
@@ -192,6 +198,10 @@ def has_outputs?
existing_output_artefact_paths.size == output_artefact_paths.size
end
+ def publishable?
+ PUBLISHABLE_SCRIPTS.include?(script) && finished? && !errored? && has_outputs?
+ end
+
def self.for_budget(budget)
group_subquery = budget.groups.select(:id)
published.where(analysable_type: "Budget", analysable_id: budget.id).or(
@@ -215,6 +225,14 @@ def self.for_process(process)
private
+ def publishing_is_allowed
+ return unless published? && published_changed? && !published_was
+
+ unless publishable?
+ errors.add(:published, :not_publishable, message: "cannot be published")
+ end
+ end
+
def set_persisted_output_if_successful
return unless finished_at.present? && error.nil?
return if persisted_output.present?
diff --git a/app/views/admin/sensemaker/jobs/help.html.erb b/app/views/admin/sensemaker/jobs/help.html.erb
new file mode 100644
index 00000000000..3158fa69ed3
--- /dev/null
+++ b/app/views/admin/sensemaker/jobs/help.html.erb
@@ -0,0 +1 @@
+<%= render Admin::Sensemaker::HelpComponent.new %>
diff --git a/app/views/admin/sensemaker/jobs/index.html.erb b/app/views/admin/sensemaker/jobs/index.html.erb
new file mode 100644
index 00000000000..a3701fef857
--- /dev/null
+++ b/app/views/admin/sensemaker/jobs/index.html.erb
@@ -0,0 +1 @@
+<%= render Admin::Sensemaker::IndexComponent.new(@sensemaker_jobs, @running_jobs) %>
diff --git a/app/views/admin/sensemaker/jobs/new.html.erb b/app/views/admin/sensemaker/jobs/new.html.erb
new file mode 100644
index 00000000000..937bfbe61f9
--- /dev/null
+++ b/app/views/admin/sensemaker/jobs/new.html.erb
@@ -0,0 +1 @@
+<%= render Admin::Sensemaker::NewComponent.new(@sensemaker_job, @search_results, @result_count) %>
diff --git a/app/views/admin/sensemaker/jobs/preview.js.erb b/app/views/admin/sensemaker/jobs/preview.js.erb
new file mode 100644
index 00000000000..4f3531fedaa
--- /dev/null
+++ b/app/views/admin/sensemaker/jobs/preview.js.erb
@@ -0,0 +1 @@
+$('#input-preview').html("<%= j @result %>");
diff --git a/app/views/admin/sensemaker/jobs/show.html.erb b/app/views/admin/sensemaker/jobs/show.html.erb
new file mode 100644
index 00000000000..b3ad30cd094
--- /dev/null
+++ b/app/views/admin/sensemaker/jobs/show.html.erb
@@ -0,0 +1 @@
+<%= render Admin::Sensemaker::JobShowComponent.new(@sensemaker_job, @child_jobs) %>
diff --git a/config/locales/en/admin.yml b/config/locales/en/admin.yml
index 1f7552c1fcd..13fcb7eee5c 100644
--- a/config/locales/en/admin.yml
+++ b/config/locales/en/admin.yml
@@ -784,6 +784,7 @@ en:
budgets: "Budgets"
layouts: "Layouts"
machine_learning: "AI / Machine Learning"
+ sensemaker: "Sensemaker"
mailers: "Emails"
management: "Management"
welcome: "Welcome"
@@ -805,6 +806,7 @@ en:
comments: "Comments"
local_census_records: Local census
machine_learning: "AI / Machine learning"
+ sensemaker: "Sensemaker"
multitenancy: Multitenancy
administrators:
index:
@@ -1759,6 +1761,119 @@ en:
created: Created records
local_census_records:
no_records_found: No records found.
+ sensemaker:
+ cancel_alert: "This action will cancel the current script and it will be necessary to run the script again."
+ feature_disabled: "This feature is disabled. To use Sensemaker you can enable it from the %{link}."
+ feature_disabled_link: "settings page"
+ help_text: "This functionality is experimental."
+ index:
+ title: Understand conversations with Sensemaker
+ tab_analysis: "Sensemaker Analysis"
+ tab_help: "Help"
+ currently_running: "Currently running"
+ past_runs: "Past runs"
+ table_item: "Item"
+ table_script: "Script"
+ table_started_at: "Started at"
+ table_status: "Status"
+ table_actions: "Actions"
+ cancel_all: "Cancel all"
+ new_run: "New Run"
+ new:
+ title: New Sensemaker analysis
+ select_target: "Select a target from the results"
+ results: "results"
+ analysing: "Analysing"
+ review_context: "Review the additional context, then select a script to run."
+ additional_context_label: "Additional context"
+ additional_context_hint: "Optional text that helps guide the analysis. For example, briefly describe what the conversation is about so the AI has useful background. The target's title and description are already included."
+ generate_summary: "Generate summary"
+ generate_report: "Generate report"
+ advanced_options: "Advanced options"
+ expert_options_hint: "For expert users. Run any Sensemaker script by choosing from the input."
+ groups: "Groups"
+ segment_by_option: "Segment by option"
+ no_free_text_to_analyse: "No free text to analyse for this question"
+ or_analyse_all_proposals: "or analyse all proposals"
+ search_placeholder: "Search for %{model_label}..."
+ clear_search: "Clear search"
+ select_script_blank: "Select a script..."
+ run: "Run"
+ cancel: "← Cancel"
+ download_input_csv: "Download input CSV"
+ job_row:
+ job_id_link: "Job #%{id}"
+ unpublish: "Unpublish"
+ publish: "Publish"
+ job_show:
+ details: "Details"
+ job_id: "Job ID"
+ script: "Script"
+ status: "Status"
+ comments_analysed: "Comments Analysed"
+ created: "Created"
+ started: "Started"
+ finished: "Finished"
+ parent_job: "Parent Job"
+ job_spawned_by: "Job spawned by"
+ target_type: "Target Type"
+ target_title: "Target Title"
+ additional_context: "Additional context"
+ error_report: "Error report"
+ error_log: "Error Log"
+ prerequisite_jobs: "Prerequisite Jobs"
+ prerequisite_jobs_help: "These jobs were created as prerequisites for this job"
+ actions: "Actions"
+ files: "Files"
+ input_files: "Input files"
+ output_files: "Output files"
+ none_available: "None available"
+ select_file_download: "Select a file to download."
+ back_to_jobs: "Back to Jobs"
+ analysable_all_proposals: "All Proposals"
+ analysable_deleted: "(deleted)"
+ status_completed_at: "Completed at %{time}"
+ status_failed_at: "Failed at %{time}"
+ status_started_at: "Started at %{time}"
+ status_cancelled_at: "Cancelled at %{time}"
+ status_created_at: "Created at %{time}"
+ result:
+ selected: "Selected"
+ help:
+ title_1: "What is Sensemaker?"
+ description_1: "Sensemaker is a tool that helps you make sense of large-scale conversations."
+ title_2: "How does Sensemaker work?"
+ description_2: "Sensemaker uses LLMs (large language models) to analyse conversations and identify patterns. Sensemaker will perform an analysis on the comments associated with a target resource e.g. a debate, proposal, poll, topic, legislation process. Details about the target resource (e.g. title, description) will be used as additional context to support the analysis."
+ title_3: "How to use Sensemaker?"
+ description_3: "To use Sensemaker, go to the Sensemaker Analysis tab, then click the 'New Run' button. Search for a target resource and select it. Add any additional context if needed and select a script to run."
+ title_4: "Available scripts"
+ description_4: "The following scripts are available:"
+ notice:
+ cancelled_jobs: "All sensemaker jobs have been cancelled."
+ script_info: "The script has been queued successfully."
+ script_required: "Please select a script from the expert options, or use Generate Summary / Generate Report."
+ output_file_not_found: "Output file not found"
+ deleted_job: "Analysis job deleted successfully"
+ cannot_publish: "The analysis cannot be published because it is not finished or has errors or does not have an output file."
+ published: "The analysis has been published successfully."
+ unpublished: "The analysis has been unpublished successfully."
+ delete_alert: "Are you sure you want to delete this analysis? This action can't be undone"
+ scripts:
+ health_check_runner_ts:
+ title: "Perform health check"
+ description: "Health check to verify sensemaker is working"
+ categorization_runner_ts:
+ title: "Categorise"
+ description: "Categorise comments into topics and subtopics"
+ runner_ts:
+ title: "Summarise"
+ description: "Generate a summary of the conversation"
+ advanced_runner_ts:
+ title: "Analyse"
+ description: "Analyse the conversation with statistics"
+ sensemaking_report_ui:
+ title: "Report"
+ description: "Analyse and generate a report in HTML format"
machine_learning:
cancel: "Cancel operation"
cancel_alert: "This action will cancel the current script and it will be necessary to run the script again."
diff --git a/config/locales/es/admin.yml b/config/locales/es/admin.yml
index 54089c23695..67c15126ba0 100644
--- a/config/locales/es/admin.yml
+++ b/config/locales/es/admin.yml
@@ -1809,6 +1809,119 @@ es:
tags: "Etiquetas"
tags_description: "Genera etiquetas automáticas para todos los elementos que pueden ser etiquetados."
title: "IA / Machine learning"
+ sensemaker:
+ cancel_alert: "Esta acción cancelará el script actual y será necesario ejecutar el script de nuevo."
+ feature_disabled: "Esta funcionalidad está deshabilitada. Para utilizar Sensemaker puedes habilitarla desde la %{link}."
+ feature_disabled_link: "página de configuración"
+ help_text: "Esta funcionalidad es experimental."
+ index:
+ title: Entender conversaciones con Sensemaker
+ tab_analysis: "Análisis Sensemaker"
+ tab_help: "Ayuda"
+ currently_running: "Ejecutándose actualmente"
+ past_runs: "Ejecuciones anteriores"
+ table_item: "Elemento"
+ table_script: "Script"
+ table_started_at: "Iniciado a las"
+ table_status: "Estado"
+ table_actions: "Acciones"
+ cancel_all: "Cancelar todo"
+ new_run: "Nueva ejecución"
+ new:
+ title: Nuevo análisis Sensemaker
+ select_target: "Selecciona un objetivo de los resultados"
+ results: "resultados"
+ analysing: "Analizando"
+ review_context: "Revisa el contexto adicional, luego selecciona un script para ejecutar."
+ additional_context_label: "Contexto adicional"
+ additional_context_hint: "Texto opcional que ayuda a orientar el análisis. Por ejemplo, describe brevemente de qué trata la conversación para que la IA tenga contexto. El título y la descripción del objetivo ya se incluyen."
+ generate_summary: "Generar resumen"
+ generate_report: "Generar informe"
+ advanced_options: "Opciones avanzadas"
+ expert_options_hint: "Para usuarios expertos. Ejecuta cualquier script de Sensemaker eligiendo en el desplegable."
+ groups: "Grupos"
+ segment_by_option: "Segmentar por opción"
+ no_free_text_to_analyse: "No hay texto libre para analizar en esta pregunta"
+ or_analyse_all_proposals: "o analizar todas las propuestas"
+ search_placeholder: "Buscar %{model_label}..."
+ clear_search: "Limpiar búsqueda"
+ select_script_blank: "Seleccionar un script..."
+ run: "Ejecutar"
+ cancel: "← Cancelar"
+ download_input_csv: "Descargar CSV de entrada"
+ job_row:
+ job_id_link: "Trabajo #%{id}"
+ unpublish: "Despublicar"
+ publish: "Publicar"
+ job_show:
+ details: "Detalles"
+ job_id: "ID del trabajo"
+ script: "Script"
+ status: "Estado"
+ comments_analysed: "Comentarios analizados"
+ created: "Creado"
+ started: "Iniciado"
+ finished: "Finalizado"
+ parent_job: "Trabajo padre"
+ job_spawned_by: "Trabajo generado por"
+ target_type: "Tipo de objetivo"
+ target_title: "Título del objetivo"
+ additional_context: "Contexto adicional"
+ error_report: "Informe de error"
+ error_log: "Registro de errores"
+ prerequisite_jobs: "Trabajos prerrequisito"
+ prerequisite_jobs_help: "Estos trabajos se crearon como prerrequisitos para este trabajo"
+ actions: "Acciones"
+ files: "Archivos"
+ input_files: "Archivos de entrada"
+ output_files: "Archivos de salida"
+ none_available: "Ninguno disponible"
+ select_file_download: "Selecciona un archivo para descargar."
+ back_to_jobs: "Volver a trabajos"
+ analysable_all_proposals: "Todas las propuestas"
+ analysable_deleted: "(eliminado)"
+ status_completed_at: "Completado el %{time}"
+ status_failed_at: "Fallido el %{time}"
+ status_started_at: "Iniciado el %{time}"
+ status_cancelled_at: "Cancelado el %{time}"
+ status_created_at: "Creado el %{time}"
+ result:
+ selected: "Seleccionado"
+ help:
+ title_1: "¿Qué es Sensemaker?"
+ description_1: "Sensemaker es una herramienta que te ayuda a dar sentido a conversaciones a gran escala."
+ title_2: "¿Cómo funciona Sensemaker?"
+ description_2: "Sensemaker utiliza LLMs (modelos de lenguaje grandes) para analizar conversaciones e identificar patrones. Sensemaker realizará un análisis sobre los comentarios asociados con un recurso objetivo, por ejemplo, un debate, propuesta, encuesta, tema, proceso legislativo. Los detalles sobre el recurso objetivo (por ejemplo, título, descripción) se utilizarán como contexto adicional para apoyar el análisis."
+ title_3: "¿Cómo usar Sensemaker?"
+ description_3: "Para usar Sensemaker, ve a la pestaña Análisis Sensemaker, luego haz clic en el botón 'Nueva Ejecución'. Busca un recurso objetivo y selecciónalo. Añade cualquier contexto adicional si es necesario y selecciona un script para ejecutar."
+ title_4: "Scripts disponibles"
+ description_4: "Los siguientes scripts están disponibles:"
+ notice:
+ cancelled_jobs: "Todos los trabajos de sensemaker han sido cancelados."
+ script_info: "El script se ha encolado correctamente."
+ script_required: "Selecciona un script en las opciones avanzadas o usa Generar resumen / Generar informe."
+ output_file_not_found: "Archivo de salida no encontrado"
+ deleted_job: "Trabajo de análisis eliminado correctamente"
+ cannot_publish: "El análisis no se puede publicar porque no está terminado o tiene errores o no tiene un archivo de salida."
+ published: "El análisis se ha publicado correctamente."
+ unpublished: "El análisis se ha despublicado correctamente."
+ delete_alert: "¿Seguro que quieres eliminar este análisis? Esta acción no se puede deshacer"
+ scripts:
+ health_check_runner_ts:
+ title: "Realizar verificación de salud"
+ description: "Verificación de salud para comprobar que sensemaker funciona"
+ categorization_runner_ts:
+ title: "Categorizar"
+ description: "Categorizar comentarios en temas y subtemas"
+ runner_ts:
+ title: "Resumir"
+ description: "Generar un resumen de la conversación"
+ advanced_runner_ts:
+ title: "Analizar"
+ description: "Analizar la conversación con estadísticas"
+ sensemaking_report_ui:
+ title: "Informe"
+ description: "Analizar y generar un informe en formato HTML"
cookies:
vendors:
empty: No se han encontrado cookies de terceros
diff --git a/config/routes/admin.rb b/config/routes/admin.rb
index 71c28e8eea5..76751d2b079 100644
--- a/config/routes/admin.rb
+++ b/config/routes/admin.rb
@@ -305,6 +305,19 @@
delete :cancel, on: :collection
end
+ namespace :sensemaker do
+ resources :jobs, only: [:index, :show, :new, :create, :destroy] do
+ member do
+ get :download
+ patch :publish
+ patch :unpublish
+ end
+ post :preview, on: :collection
+ delete :cancel, on: :collection
+ get :help, on: :collection
+ end
+ end
+
namespace :cookies do
resources :vendors, except: [:index, :show]
end
diff --git a/spec/components/admin/sensemaker/job_component_helpers_spec.rb b/spec/components/admin/sensemaker/job_component_helpers_spec.rb
new file mode 100644
index 00000000000..771d0a0e65b
--- /dev/null
+++ b/spec/components/admin/sensemaker/job_component_helpers_spec.rb
@@ -0,0 +1,155 @@
+require "rails_helper"
+
+describe Admin::Sensemaker::JobComponentHelpers do
+ let(:test_class) do
+ Class.new do
+ include Admin::Sensemaker::JobComponentHelpers
+
+ attr_reader :job
+
+ def initialize(job)
+ @job = job
+ end
+ end
+ end
+
+ let(:user) { create(:user) }
+ let(:debate) { create(:debate) }
+ let(:sensemaker_job) do
+ create(:sensemaker_job, user: user, analysable_type: "Debate", analysable_id: debate.id)
+ end
+ let(:component) { test_class.new(sensemaker_job) }
+
+ describe "#job_status_class" do
+ it "returns the correct CSS class for running status" do
+ expect(component.job_status_class).to eq("job-status-running")
+ end
+
+ context "when job is completed" do
+ before do
+ sensemaker_job.update!(finished_at: Time.current)
+ end
+
+ it "returns completed status class" do
+ expect(component.job_status_class).to eq("job-status-completed")
+ end
+ end
+
+ context "when job is failed" do
+ before do
+ sensemaker_job.update!(finished_at: Time.current, error: "Test error")
+ end
+
+ it "returns failed status class" do
+ expect(component.job_status_class).to eq("job-status-failed")
+ end
+ end
+ end
+
+ describe "#analysable_title" do
+ it "returns the analysable title" do
+ expect(component.analysable_title).to eq(debate.title)
+ end
+
+ context "when analysable is deleted" do
+ before do
+ allow(sensemaker_job).to receive(:analysable).and_return(nil)
+ end
+
+ it "returns deleted message" do
+ expect(component.analysable_title).to eq("(deleted)")
+ end
+ end
+ end
+
+ describe "#has_error?" do
+ context "when job has no error" do
+ it "returns false" do
+ expect(component.has_error?).to be false
+ end
+ end
+
+ context "when job has error but status is not Failed" do
+ before do
+ sensemaker_job.update!(started_at: Time.current, finished_at: nil, error: nil)
+ end
+
+ it "returns false" do
+ expect(sensemaker_job.status).to eq("Running")
+ expect(component.has_error?).to be false
+ end
+ end
+
+ context "when job has error and status is Failed" do
+ before do
+ sensemaker_job.update!(error: "Test error", finished_at: Time.current)
+ end
+
+ it "returns true" do
+ expect(component.has_error?).to be true
+ end
+ end
+ end
+
+ describe "#can_download?" do
+ context "when job is not finished" do
+ it "returns false" do
+ expect(component.can_download?).to be false
+ end
+ end
+
+ context "when job is finished but has no output" do
+ before do
+ sensemaker_job.update!(finished_at: Time.current, persisted_output: nil)
+ end
+
+ it "returns true (can download even without persisted_output if no error)" do
+ expect(component.can_download?).to be true
+ end
+ end
+
+ context "when job is finished and has output" do
+ before do
+ sensemaker_job.update!(
+ finished_at: Time.current,
+ persisted_output: "/path/to/output.html"
+ )
+ end
+
+ it "returns true" do
+ expect(component.can_download?).to be true
+ end
+ end
+
+ context "when job is finished but has error" do
+ before do
+ sensemaker_job.update!(
+ finished_at: Time.current,
+ error: "Test error",
+ persisted_output: "/path/to/output.html"
+ )
+ end
+
+ it "returns false" do
+ expect(component.can_download?).to be false
+ end
+ end
+ end
+
+ describe "#parent_job?" do
+ context "when job has no parent" do
+ it "returns true" do
+ expect(component.parent_job?).to be true
+ end
+ end
+
+ context "when job has a parent" do
+ let(:parent_job) { create(:sensemaker_job) }
+ let(:sensemaker_job) { create(:sensemaker_job, parent_job: parent_job) }
+
+ it "returns false" do
+ expect(component.parent_job?).to be false
+ end
+ end
+ end
+end
diff --git a/spec/components/admin/sensemaker/job_row_component_spec.rb b/spec/components/admin/sensemaker/job_row_component_spec.rb
new file mode 100644
index 00000000000..ce088b92ba2
--- /dev/null
+++ b/spec/components/admin/sensemaker/job_row_component_spec.rb
@@ -0,0 +1,45 @@
+require "rails_helper"
+
+describe Admin::Sensemaker::JobRowComponent do
+ let(:user) { create(:user) }
+ let(:debate) { create(:debate) }
+ let(:sensemaker_job) do
+ create(:sensemaker_job, user: user, analysable_type: "Debate", analysable_id: debate.id)
+ end
+ let(:component) { Admin::Sensemaker::JobRowComponent.new(sensemaker_job) }
+
+ describe "#initialize" do
+ it "sets the job" do
+ expect(component.job).to eq(sensemaker_job)
+ end
+ end
+
+ describe "#css_classes" do
+ context "when job is a parent job" do
+ it "returns parent-job class" do
+ expect(component.css_classes).to eq("job-row parent-job")
+ end
+ end
+
+ context "when job is a child job" do
+ let(:parent_job) { create(:sensemaker_job) }
+ let(:sensemaker_job) { create(:sensemaker_job, parent_job: parent_job) }
+
+ it "returns child-job class" do
+ expect(component.css_classes).to eq("job-row child-job")
+ end
+ end
+ end
+
+ describe "shared helper methods" do
+ it "includes JobComponentHelpers methods" do
+ expect(component).to respond_to(:job_status_class)
+ expect(component).to respond_to(:analysable_title)
+ expect(component).to respond_to(:has_error?)
+ expect(component).to respond_to(:can_download?)
+ expect(component).to respond_to(:status_text)
+ expect(component).to respond_to(:parent_job?)
+ expect(component).to respond_to(:parent_job)
+ end
+ end
+end
diff --git a/spec/components/admin/sensemaker/job_show_component_spec.rb b/spec/components/admin/sensemaker/job_show_component_spec.rb
new file mode 100644
index 00000000000..c72e6a2d518
--- /dev/null
+++ b/spec/components/admin/sensemaker/job_show_component_spec.rb
@@ -0,0 +1,113 @@
+require "rails_helper"
+
+describe Admin::Sensemaker::JobShowComponent do
+ let(:user) { create(:user) }
+ let(:debate) { create(:debate) }
+ let(:sensemaker_job) do
+ create(:sensemaker_job, user: user, analysable_type: "Debate", analysable_id: debate.id)
+ end
+ let(:child_jobs) { [] }
+ let(:component) { Admin::Sensemaker::JobShowComponent.new(sensemaker_job, child_jobs) }
+
+ before do
+ Setting["feature.sensemaker"] = true
+ end
+
+ describe "#enabled?" do
+ context "when sensemaker feature is enabled" do
+ it "returns true" do
+ expect(component.enabled?).to be_truthy
+ end
+ end
+
+ context "when sensemaker feature is disabled" do
+ before do
+ Setting["feature.sensemaker"] = nil
+ end
+
+ it "returns false" do
+ expect(component.enabled?).to be_falsy
+ end
+ end
+ end
+
+ describe "rendering" do
+ it "renders the component with job details" do
+ render_inline(component)
+ expect(page).to have_content("Sensemaker Job ##{sensemaker_job.id}")
+ end
+
+ context "when job can be downloaded" do
+ let(:input_path) do
+ File.join(Sensemaker::Paths.sensemaker_data_folder, "input-#{sensemaker_job.id}.csv")
+ end
+ let(:artefact_path) do
+ File.join(Sensemaker::Paths.sensemaker_data_folder, sensemaker_job.output_file_name)
+ end
+ before do
+ sensemaker_job.update!(
+ finished_at: Time.current,
+ error: nil,
+ input_file: input_path
+ )
+
+ data_folder = Sensemaker::Paths.sensemaker_data_folder
+ FileUtils.mkdir_p(data_folder)
+ File.write(input_path, "comment-id,comment_text\n1,test")
+ File.write(artefact_path, "test")
+ end
+
+ it "renders grouped download links for input and output files" do
+ render_inline(component)
+
+ expect(page).to have_content("Files")
+ expect(page).to have_content("Input files")
+ expect(page).to have_content("Output files")
+ expect(page).to have_link(File.basename(input_path))
+ expect(page).to have_link(File.basename(artefact_path))
+ end
+ end
+
+ context "when job has errors" do
+ before do
+ sensemaker_job.update!(
+ finished_at: Time.current,
+ error: "Test error message"
+ )
+ end
+
+ it "renders error section" do
+ render_inline(component)
+
+ expect(page).to have_content("Error report")
+ end
+ end
+
+ context "when job has child jobs" do
+ let(:child_job1) { create(:sensemaker_job, parent_job: sensemaker_job) }
+ let(:child_job2) { create(:sensemaker_job, parent_job: sensemaker_job) }
+ let(:child_jobs) { [child_job1, child_job2] }
+
+ it "renders prerequisite jobs section" do
+ render_inline(component)
+
+ expect(page).to have_content("Prerequisite Jobs")
+ expect(page).to have_content("These jobs were created as prerequisites for this job")
+ expect(page).to have_content("Job ##{child_job1.id}")
+ expect(page).to have_content("Job ##{child_job2.id}")
+ end
+ end
+ end
+
+ describe "shared helper methods" do
+ it "includes JobComponentHelpers methods" do
+ expect(component).to respond_to(:job_status_class)
+ expect(component).to respond_to(:analysable_title)
+ expect(component).to respond_to(:has_error?)
+ expect(component).to respond_to(:can_download?)
+ expect(component).to respond_to(:status_text)
+ expect(component).to respond_to(:parent_job?)
+ expect(component).to respond_to(:parent_job)
+ end
+ end
+end
diff --git a/spec/controllers/admin/sensemaker/jobs_controller_spec.rb b/spec/controllers/admin/sensemaker/jobs_controller_spec.rb
new file mode 100644
index 00000000000..88a8b1d5078
--- /dev/null
+++ b/spec/controllers/admin/sensemaker/jobs_controller_spec.rb
@@ -0,0 +1,593 @@
+require "rails_helper"
+
+describe Admin::Sensemaker::JobsController do
+ let(:admin) { create(:administrator).user }
+ let(:user) { create(:user) }
+ let(:debate) { create(:debate) }
+ let(:proposal) { create(:proposal) }
+ let(:sensemaker_job) do
+ create(:sensemaker_job, user: admin, analysable_type: "Debate", analysable_id: debate.id)
+ end
+
+ before { sign_in(admin) }
+
+ describe "GET #index" do
+ it "returns successful response" do
+ get :index
+
+ expect(response).to have_http_status(:ok)
+ end
+ end
+
+ describe "GET #show" do
+ it "returns successful response" do
+ get :show, params: { id: sensemaker_job.id }
+
+ expect(response).to have_http_status(:ok)
+ end
+ end
+
+ describe "GET #download" do
+ let(:job) { sensemaker_job }
+ let(:data_folder) { Sensemaker::Paths.sensemaker_data_folder.to_s }
+
+ context "when artefact param is provided and valid" do
+ let(:basename) { "artefact-#{SecureRandom.hex}.json" }
+ let(:tmp_file) { File.join(data_folder, basename) }
+
+ before do
+ FileUtils.mkdir_p(File.dirname(tmp_file))
+ File.write(tmp_file, "{}")
+ allow_any_instance_of(Sensemaker::Job).to receive(:output_artefact_paths)
+ .and_return([tmp_file])
+ end
+
+ after do
+ FileUtils.rm_f(tmp_file)
+ end
+
+ it "sends the requested artefact file" do
+ get :download, params: { id: job.id, artefact: basename }
+
+ expect(response).to have_http_status(:ok)
+ expect(response.header["Content-Disposition"]).to include(basename)
+ end
+ end
+
+ context "when input artefact param is provided and valid" do
+ let(:basename) { "input-#{SecureRandom.hex}.csv" }
+ let(:tmp_file) { File.join(data_folder, basename) }
+
+ before do
+ FileUtils.mkdir_p(File.dirname(tmp_file))
+ File.write(tmp_file, "comment-id,comment_text\n1,test")
+ job.update!(input_file: tmp_file)
+ end
+
+ after do
+ FileUtils.rm_f(tmp_file)
+ end
+
+ it "sends the requested input artefact file" do
+ get :download, params: { id: job.id, artefact: basename }
+
+ expect(response).to have_http_status(:ok)
+ expect(response.header["Content-Disposition"]).to include(basename)
+ end
+ end
+
+ context "when artefact param is invalid" do
+ it "redirects to show with alert" do
+ allow_any_instance_of(Sensemaker::Job).to receive(:output_artefact_paths)
+ .and_return([])
+
+ get :download, params: { id: job.id, artefact: "nonexistent.json" }
+
+ expect(response).to redirect_to(admin_sensemaker_job_path(job))
+ expect(flash[:alert]).to be_present
+ end
+ end
+
+ context "when no artefact param and persisted_output exists" do
+ let(:tmp_file) { Rails.root.join("tmp", "persisted-#{SecureRandom.hex}.html").to_s }
+
+ before do
+ FileUtils.mkdir_p(File.dirname(tmp_file))
+ File.write(tmp_file, "")
+ job.update!(persisted_output: tmp_file)
+ end
+
+ after do
+ FileUtils.rm_f(tmp_file)
+ end
+
+ it "sends the persisted_output file" do
+ get :download, params: { id: job.id }
+
+ expect(response).to have_http_status(:ok)
+ expect(response.header["Content-Disposition"]).to include(File.basename(tmp_file))
+ end
+ end
+
+ context "when no artefact param and no persisted_output" do
+ it "redirects to index with not found alert" do
+ allow_any_instance_of(Sensemaker::Job).to receive(:persisted_output).and_return(nil)
+
+ get :download, params: { id: job.id }
+
+ expect(response).to redirect_to(admin_sensemaker_jobs_path)
+ expect(flash[:alert]).to be_present
+ end
+ end
+ end
+
+ describe "GET #new" do
+ it "returns successful response" do
+ get :new
+
+ expect(response).to have_http_status(:ok)
+ end
+
+ context "with target_type and target_id params" do
+ it "processes target parameters successfully" do
+ get :new, params: { target_type: "Debate", target_id: debate.id }
+
+ expect(response).to have_http_status(:ok)
+ end
+ end
+
+ context "with search query" do
+ it "handles Legislation::Process search" do
+ process = create(:legislation_process)
+ create(:legislation_proposal, process: process)
+ create(:legislation_question, process: process)
+
+ get :new, params: { query: process.title, query_type: "Legislation::Process" }
+
+ expect(response).to have_http_status(:ok)
+ end
+
+ it "handles other model type search" do
+ get :new, params: { query: "test", query_type: "Debate" }
+
+ expect(response).to have_http_status(:ok)
+ end
+ end
+ end
+
+ describe "POST #create" do
+ let(:valid_params) do
+ {
+ sensemaker_job: {
+ analysable_type: "Debate",
+ analysable_id: debate.id,
+ script: "categorization_runner.ts",
+ additional_context: "Test context"
+ }
+ }
+ end
+
+ it "creates a new sensemaker job and runs it" do
+ allow_any_instance_of(Sensemaker::JobRunner).to receive(:check_dependencies?).and_return(false)
+ allow_any_instance_of(Sensemaker::JobRunner).to receive(:prepare_input_data)
+ allow_any_instance_of(Sensemaker::JobRunner).to receive(:execute_script).and_return("")
+
+ expect do
+ post :create, params: valid_params
+ end.to change(Sensemaker::Job, :count).by(1)
+
+ job = Sensemaker::Job.last
+ expect(job.user).to eq(admin)
+ expect(job.analysable_type).to eq("Debate")
+ expect(job.analysable_id).to eq(debate.id)
+ expect(job.script).to eq("categorization_runner.ts")
+ expect(job.started_at).to be_present
+ end
+
+ it "redirects to index with success notice" do
+ allow(Sensemaker::JobRunner).to receive(:new).and_return(double(run_synchronously: true))
+
+ post :create, params: valid_params
+
+ expect(response).to redirect_to(admin_sensemaker_jobs_path)
+ end
+
+ context "with quick_action" do
+ it "creates job with runner.ts when quick_action is summary" do
+ allow_any_instance_of(Sensemaker::JobRunner).to receive(:check_dependencies?).and_return(false)
+ allow_any_instance_of(Sensemaker::JobRunner).to receive(:prepare_input_data)
+ allow_any_instance_of(Sensemaker::JobRunner).to receive(:execute_script).and_return("")
+
+ post :create, params: {
+ sensemaker_job: {
+ analysable_type: "Debate",
+ analysable_id: debate.id,
+ additional_context: "Test"
+ },
+ quick_action: "summary"
+ }
+
+ job = Sensemaker::Job.last
+ expect(job.script).to eq("runner.ts")
+ end
+
+ it "creates job with sensemaking-report-ui when quick_action is report" do
+ allow_any_instance_of(Sensemaker::JobRunner).to receive(:check_dependencies?).and_return(false)
+ allow_any_instance_of(Sensemaker::JobRunner).to receive(:prepare_input_data)
+ allow_any_instance_of(Sensemaker::JobRunner).to receive(:execute_script).and_return("")
+
+ post :create, params: {
+ sensemaker_job: {
+ analysable_type: "Debate",
+ analysable_id: debate.id,
+ additional_context: "Test"
+ },
+ quick_action: "report"
+ }
+
+ job = Sensemaker::Job.last
+ expect(job.script).to eq("sensemaking-report-ui")
+ end
+ end
+
+ context "when script is missing and no quick_action" do
+ it "redirects to new with script_required alert" do
+ post :create, params: {
+ sensemaker_job: {
+ analysable_type: "Debate",
+ analysable_id: debate.id,
+ additional_context: "Test"
+ }
+ }
+
+ expect(response).to redirect_to(new_admin_sensemaker_job_path(target_type: "Debate",
+ target_id: debate.id))
+ expect(flash[:alert]).to eq(I18n.t("admin.sensemaker.notice.script_required"))
+ end
+ end
+ end
+
+ describe "GET #preview" do
+ let(:valid_params) do
+ {
+ sensemaker_job: {
+ analysable_type: "Debate",
+ analysable_id: debate.id,
+ script: "categorization_runner.ts"
+ }
+ }
+ end
+
+ it "renders preview for valid analysable" do
+ get :preview, params: valid_params, format: :html
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Additional context")
+ expect(response.body).to include("Input CSV")
+ expect(response.body).to include("comment-id,comment_text")
+ end
+
+ it "handles missing analysable" do
+ get :preview, params: { sensemaker_job: { analysable_type: "Debate", analysable_id: 999 }}
+
+ expect(response).to have_http_status(:not_found)
+ expect(response.body).to include("Error: Target not found")
+ end
+
+ it "responds with CSV format" do
+ get :preview, params: valid_params, format: :csv
+
+ expect(response.content_type).to include("text/csv")
+ end
+ end
+
+ describe "DELETE #destroy" do
+ it "destroys the sensemaker job" do
+ delete :destroy, params: { id: sensemaker_job.id }
+
+ expect { sensemaker_job.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+
+ it "redirects to index with success notice" do
+ delete :destroy, params: { id: sensemaker_job.id }
+
+ expect(response).to redirect_to(admin_sensemaker_jobs_path)
+ expect(flash[:notice]).to be_present
+ end
+ end
+
+ describe "POST #cancel" do
+ it "destroys all delayed jobs and cancels running sensemaker jobs" do
+ expect(Delayed::Job).to receive(:where)
+ .with(queue: "sensemaker").and_return(double(destroy_all: true))
+
+ running_jobs_double = double("running_jobs")
+ expect(Sensemaker::Job).to receive(:running).and_return(running_jobs_double)
+ expect(running_jobs_double).to receive(:all).and_return([sensemaker_job])
+ expect(sensemaker_job).to receive(:cancel!)
+
+ post :cancel
+
+ expect(response).to redirect_to(admin_sensemaker_jobs_path)
+ expect(flash[:notice]).to be_present
+ end
+ end
+
+ describe "PATCH #publish" do
+ let(:successful_job) do
+ output_path = Rails.root.join("tmp", "test-report-#{SecureRandom.hex}.html").to_s
+ FileUtils.mkdir_p(File.dirname(output_path))
+ File.write(output_path, "Test Report")
+
+ create(:sensemaker_job,
+ user: admin,
+ analysable_type: "Debate",
+ analysable_id: debate.id,
+ script: "sensemaking-report-ui",
+ started_at: 1.hour.ago,
+ finished_at: Time.current,
+ error: nil,
+ published: false,
+ persisted_output: output_path)
+ end
+
+ after do
+ if successful_job&.persisted_output.present?
+ FileUtils.rm_f(successful_job.persisted_output)
+ end
+ end
+
+ context "when job is eligible for publishing" do
+ it "publishes the job" do
+ patch :publish, params: { id: successful_job.id }
+
+ successful_job.reload
+ expect(successful_job.published).to be true
+ end
+
+ it "redirects to job show page with success notice" do
+ patch :publish, params: { id: successful_job.id }
+
+ expect(response).to redirect_to(admin_sensemaker_job_path(successful_job))
+ expect(flash[:notice]).to be_present
+ end
+ end
+
+ context "when job is not finished" do
+ let(:unfinished_job) do
+ create(:sensemaker_job,
+ user: admin,
+ analysable_type: "Debate",
+ analysable_id: debate.id,
+ script: "sensemaking-report-ui",
+ started_at: Time.current,
+ finished_at: nil,
+ error: nil,
+ published: false)
+ end
+
+ it "does not publish the job" do
+ patch :publish, params: { id: unfinished_job.id }
+
+ unfinished_job.reload
+ expect(unfinished_job.published).to be false
+ end
+
+ it "redirects with alert message" do
+ patch :publish, params: { id: unfinished_job.id }
+
+ expect(response).to redirect_to(admin_sensemaker_job_path(unfinished_job))
+ expect(flash[:alert]).to be_present
+ end
+ end
+
+ context "when job has error" do
+ let(:errored_job) do
+ create(:sensemaker_job,
+ user: admin,
+ analysable_type: "Debate",
+ analysable_id: debate.id,
+ script: "sensemaking-report-ui",
+ started_at: 1.hour.ago,
+ finished_at: Time.current,
+ error: "Some error occurred",
+ published: false)
+ end
+
+ it "does not publish the job" do
+ patch :publish, params: { id: errored_job.id }
+
+ errored_job.reload
+ expect(errored_job.published).to be false
+ end
+
+ it "redirects with alert message" do
+ patch :publish, params: { id: errored_job.id }
+
+ expect(response).to redirect_to(admin_sensemaker_job_path(errored_job))
+ expect(flash[:alert]).to be_present
+ end
+ end
+
+ context "when job has no output" do
+ let(:job_without_output) do
+ create(:sensemaker_job,
+ user: admin,
+ analysable_type: "Debate",
+ analysable_id: debate.id,
+ script: "sensemaking-report-ui",
+ started_at: 1.hour.ago,
+ finished_at: Time.current,
+ error: nil,
+ published: false,
+ persisted_output: nil)
+ end
+
+ it "does not publish the job" do
+ patch :publish, params: { id: job_without_output.id }
+
+ job_without_output.reload
+ expect(job_without_output.published).to be false
+ end
+
+ it "redirects with alert message" do
+ patch :publish, params: { id: job_without_output.id }
+
+ expect(response).to redirect_to(admin_sensemaker_job_path(job_without_output))
+ expect(flash[:alert]).to be_present
+ end
+ end
+
+ context "when job script is not publishable" do
+ let(:non_publishable_job) do
+ output_path = Rails.root.join("tmp", "test-report-#{SecureRandom.hex}.html").to_s
+ FileUtils.mkdir_p(File.dirname(output_path))
+ File.write(output_path, "Test Report")
+
+ create(:sensemaker_job,
+ user: admin,
+ analysable_type: "Debate",
+ analysable_id: debate.id,
+ script: "categorization_runner.ts",
+ started_at: 1.hour.ago,
+ finished_at: Time.current,
+ error: nil,
+ published: false,
+ persisted_output: output_path)
+ end
+
+ after do
+ if non_publishable_job&.persisted_output.present?
+ FileUtils.rm_f(non_publishable_job.persisted_output)
+ end
+ end
+
+ it "does not publish the job" do
+ patch :publish, params: { id: non_publishable_job.id }
+
+ non_publishable_job.reload
+ expect(non_publishable_job.published).to be false
+ end
+
+ it "redirects with alert message" do
+ patch :publish, params: { id: non_publishable_job.id }
+
+ expect(response).to redirect_to(admin_sensemaker_job_path(non_publishable_job))
+ expect(flash[:alert]).to be_present
+ end
+ end
+
+ context "when job script is runner.ts" do
+ let(:runner_job) do
+ data_folder = Sensemaker::Paths.sensemaker_data_folder
+ base_path = File.join(data_folder, "output-#{SecureRandom.hex}")
+ output_files = [
+ "#{base_path}-summary.json",
+ "#{base_path}-summary.html",
+ "#{base_path}-summary.md",
+ "#{base_path}-summaryAndSource.csv"
+ ]
+
+ FileUtils.mkdir_p(File.dirname(base_path))
+ output_files.each { |file| File.write(file, "test content") }
+
+ create(:sensemaker_job,
+ user: admin,
+ analysable_type: "Debate",
+ analysable_id: debate.id,
+ script: "runner.ts",
+ started_at: 1.hour.ago,
+ finished_at: Time.current,
+ error: nil,
+ published: false,
+ persisted_output: base_path)
+ end
+
+ after do
+ if runner_job&.persisted_output.present?
+ base_path = runner_job.persisted_output
+ [
+ "#{base_path}-summary.json",
+ "#{base_path}-summary.html",
+ "#{base_path}-summary.md",
+ "#{base_path}-summaryAndSource.csv"
+ ].each { |file| FileUtils.rm_f(file) }
+ end
+ end
+
+ it "publishes the job" do
+ patch :publish, params: { id: runner_job.id }
+
+ runner_job.reload
+ expect(runner_job.published).to be true
+ end
+
+ it "redirects to job show page with success notice" do
+ patch :publish, params: { id: runner_job.id }
+
+ expect(response).to redirect_to(admin_sensemaker_job_path(runner_job))
+ expect(flash[:notice]).to be_present
+ end
+ end
+ end
+
+ describe "PATCH #unpublish" do
+ let(:published_job) do
+ output_path = Rails.root.join("tmp", "test-report-#{SecureRandom.hex}.html").to_s
+ FileUtils.mkdir_p(File.dirname(output_path))
+ File.write(output_path, "Test Report")
+
+ create(:sensemaker_job,
+ user: admin,
+ analysable_type: "Debate",
+ analysable_id: debate.id,
+ script: "sensemaking-report-ui",
+ started_at: 1.hour.ago,
+ finished_at: Time.current,
+ error: nil,
+ published: true,
+ persisted_output: output_path)
+ end
+
+ after do
+ if published_job&.persisted_output.present?
+ FileUtils.rm_f(published_job.persisted_output)
+ end
+ end
+
+ it "unpublishes the job" do
+ patch :unpublish, params: { id: published_job.id }
+
+ published_job.reload
+ expect(published_job.published).to be false
+ end
+
+ it "redirects to job show page with success notice" do
+ patch :unpublish, params: { id: published_job.id }
+
+ expect(response).to redirect_to(admin_sensemaker_job_path(published_job))
+ expect(flash[:notice]).to be_present
+ end
+ end
+
+ describe "private methods" do
+ describe "#sensemaker_job_params" do
+ it "permits required parameters" do
+ params = ActionController::Parameters.new({
+ sensemaker_job: {
+ analysable_type: "Debate",
+ analysable_id: "123",
+ script: "test.ts",
+ additional_context: "context"
+ }
+ })
+
+ controller.params = params
+ permitted = controller.send(:sensemaker_job_params)
+
+ expect(permitted.keys).to include("analysable_type", "analysable_id", "script",
+ "additional_context")
+ end
+ end
+ end
+end
diff --git a/spec/factories/sensemaker/jobs.rb b/spec/factories/sensemaker/jobs.rb
index b10210c277b..bffe8177fc9 100644
--- a/spec/factories/sensemaker/jobs.rb
+++ b/spec/factories/sensemaker/jobs.rb
@@ -8,13 +8,15 @@
analysable_type { "Debate" }
analysable_id { create(:debate).id }
additional_context { "Test context" }
- published { true }
+ published { false }
trait :unpublished do
+ script { "runner.ts" }
published { false }
end
trait :published do
+ script { "runner.ts" }
published { true }
end
diff --git a/spec/models/abilities/administrator_spec.rb b/spec/models/abilities/administrator_spec.rb
index 3922a5fbbae..bcb7b048a9d 100644
--- a/spec/models/abilities/administrator_spec.rb
+++ b/spec/models/abilities/administrator_spec.rb
@@ -189,6 +189,10 @@
it { should be_able_to(:update, Cookies::Vendor) }
it { should be_able_to(:destroy, Cookies::Vendor) }
+ it { should be_able_to(:manage, Sensemaker::Job) }
+ it { should be_able_to(:publish, create(:sensemaker_job)) }
+ it { should be_able_to(:unpublish, create(:sensemaker_job)) }
+
describe "tenants" do
context "with multitenancy disabled" do
before { allow(Rails.application.config).to receive(:multitenancy).and_return(false) }
diff --git a/spec/models/abilities/everyone_spec.rb b/spec/models/abilities/everyone_spec.rb
index 9033171588a..ac011c94792 100644
--- a/spec/models/abilities/everyone_spec.rb
+++ b/spec/models/abilities/everyone_spec.rb
@@ -7,6 +7,7 @@
let(:user) { nil }
let(:debate) { create(:debate) }
let(:proposal) { create(:proposal) }
+ let(:sensemaker_job) { build(:sensemaker_job, :published) }
it { should be_able_to(:index, Debate) }
it { should be_able_to(:show, debate) }
@@ -31,6 +32,12 @@
it { should_not be_able_to(:create, LocalCensusRecords::Import) }
it { should_not be_able_to(:show, LocalCensusRecords::Import) }
+ it { should be_able_to(:read, sensemaker_job) }
+ it { should_not be_able_to(:read, create(:sensemaker_job, :unpublished)) }
+ it { should_not be_able_to(:manage, create(:sensemaker_job, :unpublished)) }
+ it { should_not be_able_to(:publish, create(:sensemaker_job, :unpublished)) }
+ it { should_not be_able_to(:unpublish, sensemaker_job) }
+
it { should be_able_to(:results, create(:poll, :expired, results_enabled: true)) }
it { should_not be_able_to(:results, create(:poll, :expired, results_enabled: false)) }
it { should_not be_able_to(:results, create(:poll, results_enabled: true)) }
diff --git a/spec/models/sensemaker/job_spec.rb b/spec/models/sensemaker/job_spec.rb
index f942e9a09a8..64cc7dd44a9 100644
--- a/spec/models/sensemaker/job_spec.rb
+++ b/spec/models/sensemaker/job_spec.rb
@@ -41,6 +41,66 @@
job.analysable_id = nil
expect(job).to be_valid
end
+
+ describe "#publishing_is_allowed" do
+ let(:data_folder) { "/tmp/sensemaker_test_folder/data" }
+
+ before do
+ allow(Sensemaker::Paths).to receive(:sensemaker_data_folder).and_return(data_folder)
+ allow(File).to receive(:exist?).and_return(false)
+ job.published = false
+ end
+
+ context "when job is publishable" do
+ before do
+ job.script = "sensemaking-report-ui"
+ job.finished_at = Time.current
+ job.error = nil
+ output_path = "#{data_folder}/report-#{job.id}.html"
+ allow(File).to receive(:exist?).with(output_path).and_return(true)
+ end
+
+ it "allows publishing when changing from false to true" do
+ job.published = true
+ expect(job).to be_valid
+ end
+ end
+
+ context "when job is not publishable" do
+ it "adds validation error when published is changed to true" do
+ # Set up a job that is not publishable (any condition fails)
+ job.script = "categorization_runner.ts"
+ job.finished_at = Time.current
+ job.error = nil
+ job.published = true
+
+ expect(job).not_to be_valid
+ expect(job.errors[:published]).to be_present
+ end
+ end
+
+ context "when job is already published" do
+ before do
+ job.published = true
+ job.save!(validate: false) # Save without validation to set initial state
+ end
+
+ it "does not validate when already published" do
+ job.script = "categorization_runner.ts" # Make it unpublishable
+ job.finished_at = nil
+ expect(job).to be_valid
+ end
+ end
+
+ context "when job is not published" do
+ it "does not validate publishable status" do
+ job.script = "categorization_runner.ts"
+ job.finished_at = nil
+ job.published = false
+ expect(job).to be_valid
+ end
+ end
+ end
end
describe "associations" do
@@ -412,6 +472,118 @@
%w[-summary.json -summary.html -summary.md -summaryAndSource.csv]
end
+ describe "#publishable?" do
+ let(:data_folder) { "/tmp/sensemaker_test_folder/data" }
+
+ before do
+ allow(Sensemaker::Paths).to receive(:sensemaker_data_folder).and_return(data_folder)
+ allow(File).to receive(:exist?).and_return(false)
+ end
+
+ context "when script is sensemaking-report-ui" do
+ before do
+ job.script = "sensemaking-report-ui"
+ job.finished_at = Time.current
+ job.error = nil
+ end
+
+ it "returns true when all conditions are met" do
+ output_path = "#{data_folder}/report-#{job.id}.html"
+ allow(File).to receive(:exist?).with(output_path).and_return(true)
+ expect(job.publishable?).to be true
+ end
+
+ it "returns false when job is not finished" do
+ job.finished_at = nil
+ output_path = "#{data_folder}/report-#{job.id}.html"
+ allow(File).to receive(:exist?).with(output_path).and_return(true)
+ expect(job.publishable?).to be false
+ end
+
+ it "returns false when job has errors" do
+ job.error = "Some error occurred"
+ output_path = "#{data_folder}/report-#{job.id}.html"
+ allow(File).to receive(:exist?).with(output_path).and_return(true)
+ expect(job.publishable?).to be false
+ end
+
+ it "returns false when job has no outputs" do
+ expect(job.publishable?).to be false
+ end
+ end
+
+ context "when script is runner.ts" do
+ before do
+ job.script = "runner.ts"
+ job.finished_at = Time.current
+ job.error = nil
+ end
+
+ it "returns true when all conditions are met" do
+ base_path = "#{data_folder}/output-#{job.id}"
+ allow(File).to receive(:exist?).with("#{base_path}-summary.json").and_return(true)
+ allow(File).to receive(:exist?).with("#{base_path}-summary.html").and_return(true)
+ allow(File).to receive(:exist?).with("#{base_path}-summary.md").and_return(true)
+ allow(File).to receive(:exist?).with("#{base_path}-summaryAndSource.csv").and_return(true)
+ expect(job.publishable?).to be true
+ end
+
+ it "returns false when job is not finished" do
+ job.finished_at = nil
+ base_path = "#{data_folder}/output-#{job.id}"
+ allow(File).to receive(:exist?).with("#{base_path}-summary.json").and_return(true)
+ allow(File).to receive(:exist?).with("#{base_path}-summary.html").and_return(true)
+ allow(File).to receive(:exist?).with("#{base_path}-summary.md").and_return(true)
+ allow(File).to receive(:exist?).with("#{base_path}-summaryAndSource.csv").and_return(true)
+ expect(job.publishable?).to be false
+ end
+
+ it "returns false when job has errors" do
+ job.error = "Some error occurred"
+ base_path = "#{data_folder}/output-#{job.id}"
+ allow(File).to receive(:exist?).with("#{base_path}-summary.json").and_return(true)
+ allow(File).to receive(:exist?).with("#{base_path}-summary.html").and_return(true)
+ allow(File).to receive(:exist?).with("#{base_path}-summary.md").and_return(true)
+ allow(File).to receive(:exist?).with("#{base_path}-summaryAndSource.csv").and_return(true)
+ expect(job.publishable?).to be false
+ end
+
+ it "returns false when job has no outputs" do
+ expect(job.publishable?).to be false
+ end
+ end
+
+ context "when script is not publishable" do
+ before do
+ job.finished_at = Time.current
+ job.error = nil
+ end
+
+ it "returns false for categorization_runner.ts even when other conditions are met" do
+ job.script = "categorization_runner.ts"
+ output_path = "#{data_folder}/categorization-output-#{job.id}.csv"
+ allow(File).to receive(:exist?).with(output_path).and_return(true)
+ expect(job.publishable?).to be false
+ end
+
+ it "returns false for advanced_runner.ts even when other conditions are met" do
+ job.script = "advanced_runner.ts"
+ base_path = "#{data_folder}/output-#{job.id}"
+ allow(File).to receive(:exist?).with("#{base_path}-summary.json").and_return(true)
+ allow(File).to receive(:exist?).with("#{base_path}-topic-stats.json").and_return(true)
+ allow(File).to receive(:exist?).with("#{base_path}-comments-with-scores.json").and_return(true)
+ expect(job.publishable?).to be false
+ end
+
+ it "returns false for health_check_runner.ts even when other conditions are met" do
+ job.script = "health_check_runner.ts"
+ output_path = "#{data_folder}/health-check-#{job.id}.txt"
+ allow(File).to receive(:exist?).with(output_path).and_return(true)
+ expect(job.publishable?).to be false
+ end
+ end
+ end
+
describe "#cleanup_associated_files" do
include_context "sensemaker paths stubbed"