diff --git a/app/controllers/admin/insights_controller.rb b/app/controllers/admin/insights_controller.rb new file mode 100644 index 0000000000..a9e16e5b27 --- /dev/null +++ b/app/controllers/admin/insights_controller.rb @@ -0,0 +1,48 @@ +## +# Controller for running AI insights tasks from the admin UI +# +class Admin::InsightsController < AdminController + before_action :find_info_request + before_action :find_insight, only: [:show, :destroy] + + def show + end + + def new + last = Insight.last + @insight = @info_request.insights.new( + model: last&.model, temperature: last&.temperature || 0.5, + template: last&.template + ) + end + + def create + @insight = @info_request.insights.new(insight_params) + if @insight.save + redirect_to admin_info_request_insight_path(@info_request, @insight), + notice: 'Insight was successfully created.' + else + render :new + end + end + + def destroy + @insight.destroy + redirect_to admin_request_path(@info_request), + notice: 'Insight was successfully deleted.' + end + + private + + def find_info_request + @info_request = InfoRequest.find(params[:info_request_id]) + end + + def find_insight + @insight = @info_request.insights.find(params[:id]) + end + + def insight_params + params.require(:insight).permit(:model, :temperature, :template) + end +end diff --git a/app/models/insight.rb b/app/models/insight.rb index 889fe638c8..aa9491ad0a 100644 --- a/app/models/insight.rb +++ b/app/models/insight.rb @@ -13,6 +13,9 @@ # updated_at :datetime not null # class Insight < ApplicationRecord + admin_columns exclude: [:template, :output], + include: [:duration, :prompt, :response] + after_commit :queue, on: :create belongs_to :info_request, optional: false @@ -35,12 +38,23 @@ def self.models client.tags.first['models'].map { _1['model'] }.sort end + def duration + return unless output['total_duration'] + + seconds = output['total_duration'].to_f / 1_000_000_000 + ActiveSupport::Duration.build(seconds.to_i).inspect + end + def prompt template.gsub('[initial_request]') do outgoing_messages.first.body[0...500] end end + def response + output['response'] + end + private def queue diff --git a/app/views/admin/insights/_list.html.erb b/app/views/admin/insights/_list.html.erb new file mode 100644 index 0000000000..01b0058216 --- /dev/null +++ b/app/views/admin/insights/_list.html.erb @@ -0,0 +1,34 @@ +
+ <% if insights.any? %> + + + + + + + + + + + <% insights.each do |insight| %> + + + + + + + + + + <% end %> +
IDModelTemplateCreated atUpdated atActions
<%= insight.to_param %><%= insight.model %><%= insight.temperature %><%= truncate(insight.template, length: 150) %><%= admin_date(insight.created_at) %><%= admin_date(insight.updated_at) %><%= link_to "Show", admin_info_request_insight_path(info_request, insight), class: 'btn' %>
+ <% else %> +

None yet.

+ <% end %> +
+ +
+

+ <%= link_to "New insight", new_admin_info_request_insight_path(info_request), :class => "btn btn-info" %> +

+
diff --git a/app/views/admin/insights/new.html.erb b/app/views/admin/insights/new.html.erb new file mode 100644 index 0000000000..098cb70673 --- /dev/null +++ b/app/views/admin/insights/new.html.erb @@ -0,0 +1,45 @@ +<% @title = 'New insight' %> + +
+
+ +
+
+ +<%= form_for [:admin, @info_request, @insight], html: { class: 'form form-horizontal' } do |f| %> + <%= foi_error_messages_for :insight %> + +
+ <%= f.label :model, class: 'control-label' %> +
+ <%= f.select :model, Insight.models, {}, class: 'span6' %> +
+
+ +
+ <%= f.label :temperature, class: 'control-label' %> +
+ <%= f.range_field :temperature, min: 0, max: 1, step: 0.1, class: 'span6', autocomplete: 'off', oninput: 'insight_temperature_display.value = insight_temperature.value' %> + <%= content_tag(:output, @insight.temperature, id: 'insight_temperature_display') %> +
+
+ +
+ <%= f.label :template, class: 'control-label' %> +
+ <%= f.text_area :template, class: 'span6', rows: 10 %> + +
+ Add [initial_request] to substitute in the first 500 + characters from the body of the initial outgoing message into the prompt + sent to the model. +
+
+
+ +
+ <%= submit_tag 'Create', class: 'btn btn-success' %> +
+<% end %> diff --git a/app/views/admin/insights/show.html.erb b/app/views/admin/insights/show.html.erb new file mode 100644 index 0000000000..29ea3e9050 --- /dev/null +++ b/app/views/admin/insights/show.html.erb @@ -0,0 +1,28 @@ + + + + + + + <% @insight.for_admin_column do |name, value| %> + + + + + <% end %> + +
+ ID + + <%= @insight.id %> +
+ <%= name.humanize %> + + <% if name == 'prompt' || name == 'response' %> +
<%= value&.strip %>
+ <% else %> + <%= h admin_value(value) %> + <% end %> +
+ +<%= link_to "Destroy", admin_info_request_insight_path(@info_request, @insight), method: :delete, data: { confirm: 'Are you sure?' }, class: 'btn btn-danger' %> diff --git a/app/views/admin_request/show.html.erb b/app/views/admin_request/show.html.erb index 191544d993..f5207c3dc8 100644 --- a/app/views/admin_request/show.html.erb +++ b/app/views/admin_request/show.html.erb @@ -413,3 +413,11 @@ <%= render partial: 'admin/notes/show', locals: { notes: @info_request.all_notes, notable: @info_request } %> + +
+ +

Insights

+ +<%= render partial: 'admin/insights/list', + locals: { info_request: @info_request, + insights: @info_request.insights } %> diff --git a/config/routes.rb b/config/routes.rb index 14a77c88c6..6a133e64a7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -812,6 +812,14 @@ def matches?(request) end #### + #### Admin::Insights controller + namespace :admin do + resources :info_requests, only: [], path: 'requests' do + resources :insights, only: [:show, :new, :create, :destroy] + end + end + #### + #### Api controller match '/api/v2/request.json' => 'api#create_request', :as => :api_create_request, diff --git a/spec/controllers/admin/insights_controller_spec.rb b/spec/controllers/admin/insights_controller_spec.rb new file mode 100644 index 0000000000..98a2bb44c3 --- /dev/null +++ b/spec/controllers/admin/insights_controller_spec.rb @@ -0,0 +1,97 @@ +require 'spec_helper' + +RSpec.describe Admin::InsightsController, type: :controller do + let(:info_request) { FactoryBot.create(:info_request) } + let(:insight) { FactoryBot.create(:insight, info_request: info_request) } + + describe 'GET #show' do + it 'renders the show template' do + get :show, params: { info_request_id: info_request, id: insight } + expect(response).to render_template(:show) + end + end + + describe 'GET #new' do + it 'assigns a new insight' do + get :new, params: { info_request_id: info_request } + expect(assigns(:insight)).to be_a(Insight) + expect(assigns(:insight)).to be_new_record + end + + context 'when previous insights exist' do + let!(:last_insight) do + FactoryBot.create( + :insight, model: 'Model', temperature: '0.7', template: 'Template' + ) + end + + it 'copies model, temperature and template from last insight' do + get :new, params: { info_request_id: info_request } + expect(assigns(:insight).model).to eq(last_insight.model) + expect(assigns(:insight).temperature).to eq(last_insight.temperature) + expect(assigns(:insight).template).to eq(last_insight.template) + end + end + end + + describe 'POST #create' do + let(:valid_params) do + { + info_request_id: info_request, + insight: { + model: 'TestModel', temperature: '0.3', template: 'TestTemplate' + } + } + end + + context 'with valid params' do + it 'creates a new insight' do + expect { + post :create, params: valid_params + }.to change(Insight, :count).by(1) + end + + it 'redirects to the created insight' do + post :create, params: valid_params + expect(response).to redirect_to( + admin_info_request_insight_path(info_request, Insight.last) + ) + end + end + + context 'with invalid params' do + let(:invalid_params) do + { + info_request_id: info_request, + insight: { model: nil, temperature: nil, template: nil } + } + end + + it 'renders the new template' do + post :create, params: invalid_params + expect(response).to render_template(:new) + end + end + end + + describe 'DELETE #destroy' do + let!(:insight_to_delete) do + FactoryBot.create(:insight, info_request: info_request) + end + + it 'destroys the insight' do + expect { + delete :destroy, params: { + info_request_id: info_request, id: insight_to_delete + } + }.to change(Insight, :count).by(-1) + end + + it 'redirects to the info request page' do + delete :destroy, params: { + info_request_id: info_request, id: insight_to_delete + } + expect(response).to redirect_to(admin_request_path(info_request)) + end + end +end diff --git a/spec/models/insight_spec.rb b/spec/models/insight_spec.rb index a150db81d7..f2b3a3f17b 100644 --- a/spec/models/insight_spec.rb +++ b/spec/models/insight_spec.rb @@ -82,6 +82,19 @@ end end + describe '#duration' do + it 'returns nil when total_duration is not present' do + insight = FactoryBot.build(:insight) + expect(insight.duration).to be_nil + end + + it 'returns formatted duration when total_duration exists' do + insight = FactoryBot.build(:insight) + insight.output = { 'total_duration' => 3_000_000_000 } + expect(insight.duration).to eq('3 seconds') + end + end + describe '#prompt' do it 'replaces [initial_request] with first outgoing message body' do outgoing_message = instance_double( @@ -97,4 +110,12 @@ expect(insight.prompt).to eq('Template with message content') end end + + describe '#response' do + it 'returns response from output' do + insight = FactoryBot.build(:insight) + insight.output = { 'response' => 'test response' } + expect(insight.response).to eq('test response') + end + end end