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? %>
+
+
+ ID |
+ Model |
+ Template |
+ Created at |
+ Updated at |
+ Actions |
+
+
+ <% insights.each do |insight| %>
+
+ <%= 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' %> |
+
+ <% end %>
+
+ <% 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 @@
+
+
+
+
+ ID
+ |
+
+ <%= @insight.id %>
+ |
+
+ <% @insight.for_admin_column do |name, value| %>
+
+
+ <%= name.humanize %>
+ |
+
+ <% if name == 'prompt' || name == 'response' %>
+ <%= value&.strip %>
+ <% else %>
+ <%= h admin_value(value) %>
+ <% end %>
+ |
+
+ <% 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