diff --git a/Gemfile b/Gemfile index 0023f29245..f9482d568a 100644 --- a/Gemfile +++ b/Gemfile @@ -174,6 +174,9 @@ gem 'google-cloud-storage', '~> 1.52', require: false # Storage content analyzers gem 'excel_analyzer', path: 'gems/excel_analyzer', require: false +# AI insights +gem "ollama-ai", "~> 1.3.0" + group :test do gem 'fivemat', '~> 1.3.7' gem 'webmock', '~> 3.24.0' diff --git a/Gemfile.lock b/Gemfile.lock index 0bf1671919..8f21633108 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -182,6 +182,8 @@ GEM rake (>= 12.0.0, < 14.0.0) docile (1.4.0) erubi (1.13.0) + ethon (0.16.0) + ffi (>= 1.15.0) exception_notification (4.5.0) actionmailer (>= 5.2, < 8) activesupport (>= 5.2, < 8) @@ -199,8 +201,16 @@ GEM logger faraday-net_http (3.3.0) net-http + faraday-typhoeus (1.1.0) + faraday (~> 2.0) + typhoeus (~> 1.4) fast_gettext (3.1.0) prime + ffi (1.17.0) + ffi (1.17.0-aarch64-linux-gnu) + ffi (1.17.0-arm64-darwin) + ffi (1.17.0-x86_64-darwin) + ffi (1.17.0-x86_64-linux-gnu) fivemat (1.3.7) flipper (0.28.3) concurrent-ruby (< 2) @@ -358,6 +368,10 @@ GEM oink (0.10.1) activerecord hodel_3000_compliant_logger + ollama-ai (1.3.0) + faraday (~> 2.10) + faraday-typhoeus (~> 1.1) + typhoeus (~> 1.4, >= 1.4.1) open4 (1.3.4) os (1.1.4) ostruct (0.6.0) @@ -534,6 +548,8 @@ GEM turbo-rails (2.0.11) actionpack (>= 6.0.0) railties (>= 6.0.0) + typhoeus (1.4.1) + ethon (>= 0.9.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) uber (0.1.0) @@ -628,6 +644,7 @@ DEPENDENCIES net-ssh-gateway (>= 1.1.0, < 3.0.0) nokogiri (~> 1.16.7) oink (~> 0.10.1) + ollama-ai (~> 1.3.0) open4 (~> 1.3.0) pg (~> 1.5.9) pry (~> 0.15.0) 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/jobs/insight_job.rb b/app/jobs/insight_job.rb new file mode 100644 index 0000000000..8144b3015a --- /dev/null +++ b/app/jobs/insight_job.rb @@ -0,0 +1,30 @@ +## +# InsightJob is responsible for generating InfoRequest insights using an AI +# model run via Ollama. +# +class InsightJob < ApplicationJob + queue_as :insights + + delegate :model, :temperature, :prompt, to: :@insight + + def perform(insight) + @insight = insight + + insight.update(output: results.first) + end + + private + + def results + client.generate( + { model: model, prompt: prompt, temperature: temperature, stream: false } + ) + end + + def client + Ollama.new( + credentials: { address: ENV['OLLAMA_URL'] }, + options: { server_sent_events: true } + ) + end +end diff --git a/app/models/info_request.rb b/app/models/info_request.rb index 37be186146..255a181600 100644 --- a/app/models/info_request.rb +++ b/app/models/info_request.rb @@ -133,6 +133,8 @@ def self.admin_title -> { extraction }, class_name: 'Project::Submission' + has_many :insights, dependent: :destroy + attr_reader :followup_bad_reason scope :internal, -> { where.not(user_id: nil) } diff --git a/app/models/insight.rb b/app/models/insight.rb new file mode 100644 index 0000000000..4152344d82 --- /dev/null +++ b/app/models/insight.rb @@ -0,0 +1,52 @@ +# == Schema Information +# Schema version: 20241024140606 +# +# Table name: insights +# +# id :bigint not null, primary key +# info_request_id :bigint +# model :string +# temperature :decimal(8, 2) +# template :text +# output :jsonb +# created_at :datetime not null +# 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 + has_many :outgoing_messages, through: :info_request + + serialize :output, type: Hash, coder: JSON, default: {} + + validates :model, presence: true + validates :temperature, presence: true + validates :template, presence: true + + 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 + InsightJob.perform_later(self) + end +end 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..c0c4146031 --- /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.text_field :model, 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/db/migrate/20241024140606_create_insights.rb b/db/migrate/20241024140606_create_insights.rb new file mode 100644 index 0000000000..059477abfb --- /dev/null +++ b/db/migrate/20241024140606_create_insights.rb @@ -0,0 +1,13 @@ +class CreateInsights < ActiveRecord::Migration[7.0] + def change + create_table :insights do |t| + t.references :info_request, foreign_key: true + t.string :model + t.decimal :temperature, precision: 8, scale: 2 + t.text :template + t.jsonb :output + + t.timestamps + end + end +end 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/factories/insights.rb b/spec/factories/insights.rb new file mode 100644 index 0000000000..b1209a7aa8 --- /dev/null +++ b/spec/factories/insights.rb @@ -0,0 +1,22 @@ +# == Schema Information +# Schema version: 20241024140606 +# +# Table name: insights +# +# id :bigint not null, primary key +# info_request_id :bigint +# model :string +# temperature :decimal(8, 2) +# template :text +# output :jsonb +# created_at :datetime not null +# updated_at :datetime not null +# +FactoryBot.define do + factory :insight do + association :info_request + model { 'llama' } + temperature { 0.3 } + template { 'Some template' } + end +end diff --git a/spec/jobs/insight_job_spec.rb b/spec/jobs/insight_job_spec.rb new file mode 100644 index 0000000000..be2be29cd9 --- /dev/null +++ b/spec/jobs/insight_job_spec.rb @@ -0,0 +1,34 @@ +require 'spec_helper' + +RSpec.describe InsightJob, type: :job do + let(:insight) do + FactoryBot.build('insight', model: 'gpt-3.5-turbo', temperature: 0.7) + end + + let(:client) { instance_double('Ollama::Controllers::Client') } + + let(:job) { InsightJob.new } + + before do + allow(job).to receive(:client).and_return(client) + allow(insight).to receive(:prompt).and_return('Test prompt') + allow(insight).to receive(:update) + end + + describe '#perform' do + it 'updates the insight with the generated output' do + expect(client).to receive(:generate).with( + hash_including( + model: 'gpt-3.5-turbo', + temperature: 0.7, + prompt: 'Test prompt', + stream: false + ) + ).and_return(['Generated output']) + + expect(insight).to receive(:update).with(output: 'Generated output') + + job.perform(insight) + end + end +end diff --git a/spec/models/info_request_spec.rb b/spec/models/info_request_spec.rb index 9f71b70f10..c9a07d9b1f 100644 --- a/spec/models/info_request_spec.rb +++ b/spec/models/info_request_spec.rb @@ -1408,6 +1408,11 @@ expect { info_request.destroy }. to change(AlaveteliPro::Embargo, :count).by(-1) end + + it 'destroys associated insights' do + FactoryBot.create(:insight, info_request: info_request) + expect { info_request.destroy }.to change(Insight, :count).by(-1) + end end describe '#expire' do diff --git a/spec/models/insight_spec.rb b/spec/models/insight_spec.rb new file mode 100644 index 0000000000..288776a13a --- /dev/null +++ b/spec/models/insight_spec.rb @@ -0,0 +1,99 @@ +# == Schema Information +# Schema version: 20241024140606 +# +# Table name: insights +# +# id :bigint not null, primary key +# info_request_id :bigint +# model :string +# temperature :decimal(8, 2) +# template :text +# output :jsonb +# created_at :datetime not null +# updated_at :datetime not null +# +require 'spec_helper' + +RSpec.describe Insight, type: :model do + describe 'associations' do + it 'belongs to info_request' do + insight = FactoryBot.build(:insight) + expect(insight.info_request).to be_a(InfoRequest) + end + + it 'has many outgoing_messages through info_request' do + insight = FactoryBot.build(:insight) + expect(insight.outgoing_messages).to all(be_a(OutgoingMessage)) + end + end + + describe 'validations' do + it 'requires info_request' do + insight = FactoryBot.build(:insight) + insight.info_request = nil + expect(insight).not_to be_valid + end + + it 'requires model' do + insight = FactoryBot.build(:insight) + insight.model = nil + expect(insight).not_to be_valid + end + + it 'requires temperature' do + insight = FactoryBot.build(:insight) + insight.temperature = nil + expect(insight).not_to be_valid + end + + it 'requires template' do + insight = FactoryBot.build(:insight) + insight.template = nil + expect(insight).not_to be_valid + end + end + + describe 'callbacks' do + it 'queues InsightJob after create' do + expect(InsightJob).to receive(:perform_later) + FactoryBot.create(:insight) + 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( + OutgoingMessage, body: 'message content' + ) + insight = FactoryBot.build( + :insight, template: 'Template with [initial_request]' + ) + + allow(insight).to receive(:outgoing_messages). + and_return([outgoing_message]) + + 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