labimotion 2.4.0.rc5 → 2.4.0.rc6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/labimotion/apis/generic_dataset_api.rb +29 -0
- data/lib/labimotion/apis/generic_element_api.rb +37 -0
- data/lib/labimotion/apis/segment_api.rb +19 -0
- data/lib/labimotion/helpers/dataset_helpers.rb +113 -0
- data/lib/labimotion/helpers/element_helpers.rb +178 -0
- data/lib/labimotion/helpers/generic_helpers.rb +62 -0
- data/lib/labimotion/helpers/param_helpers.rb +68 -0
- data/lib/labimotion/helpers/segment_helpers.rb +62 -0
- data/lib/labimotion/libs/ai_egress_guard.rb +84 -0
- data/lib/labimotion/libs/ai_template.rb +1482 -0
- data/lib/labimotion/libs/file_extractor.rb +210 -0
- data/lib/labimotion/version.rb +1 -1
- data/lib/labimotion.rb +3 -0
- metadata +18 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa32d7e24804c4f270efdbce75bccf847ae3d30ae1f6b265c17b9c196e9c12a6
|
|
4
|
+
data.tar.gz: b6901471f70ac6874bf68a517ea9d994a3e4054d089a2bc66e042de38e97f4fd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b6612883678081acfa046ea9956424f06b512f5ea941cac2dd219e60809f0ed308026d9e0e8c9a112e067b8718f4a2b55939211b0843100d6329a9502341ddee
|
|
7
|
+
data.tar.gz: c52a79f87e8dffb30d340be92e107e6518c96e3c28d46a6857e6355ff3e30ec399a989cce94553188b4baff83ac548d34d80083b185a523be8c31823bdc7e97c
|
|
@@ -7,6 +7,7 @@ module Labimotion
|
|
|
7
7
|
|
|
8
8
|
helpers Labimotion::GenericHelpers
|
|
9
9
|
helpers Labimotion::DatasetHelpers
|
|
10
|
+
helpers Labimotion::ParamHelpers
|
|
10
11
|
|
|
11
12
|
resource :generic_dataset do
|
|
12
13
|
namespace :klasses do
|
|
@@ -78,6 +79,34 @@ module Labimotion
|
|
|
78
79
|
end
|
|
79
80
|
end
|
|
80
81
|
|
|
82
|
+
namespace :create_ai_klass do
|
|
83
|
+
desc 'create a Generic Dataset Klass from an AI-generated template'
|
|
84
|
+
params do
|
|
85
|
+
use :create_ai_dataset_klass_params
|
|
86
|
+
end
|
|
87
|
+
post do
|
|
88
|
+
msg = create_ai_dataset_klass(params, current_user)
|
|
89
|
+
klass = Labimotion::DatasetKlassEntity.represent(Labimotion::DatasetKlass.all)
|
|
90
|
+
{ status: msg[:status], message: msg[:message], klass: klass }
|
|
91
|
+
rescue StandardError => e
|
|
92
|
+
Labimotion.log_exception(e, current_user)
|
|
93
|
+
{ error: e.message }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
namespace :refine_ai_klass do
|
|
98
|
+
desc 'refine a Generic Dataset Klass template with AI (returns the revised template; not persisted)'
|
|
99
|
+
params do
|
|
100
|
+
use :refine_ai_dataset_klass_params
|
|
101
|
+
end
|
|
102
|
+
post do
|
|
103
|
+
refine_ai_dataset_klass(params, current_user)
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
Labimotion.log_exception(e, current_user)
|
|
106
|
+
{ status: 'error', message: e.message }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
81
110
|
namespace :find_template do
|
|
82
111
|
desc 'Find best matching template for given OLS term ID'
|
|
83
112
|
params do
|
|
@@ -212,6 +212,43 @@ module Labimotion
|
|
|
212
212
|
end
|
|
213
213
|
end
|
|
214
214
|
|
|
215
|
+
namespace :create_ai_klass do
|
|
216
|
+
desc 'create a Generic Element Klass from an AI-generated template'
|
|
217
|
+
params do
|
|
218
|
+
use :create_ai_element_klass_params
|
|
219
|
+
end
|
|
220
|
+
post do
|
|
221
|
+
authenticate_admin!('elements')
|
|
222
|
+
msg = create_ai_element_klass(params, current_user)
|
|
223
|
+
{ status: msg[:status], message: msg[:message],
|
|
224
|
+
klass: Labimotion::ElementKlassEntity.represent(Labimotion::ElementKlass.where(is_active: true)) }
|
|
225
|
+
rescue StandardError => e
|
|
226
|
+
Labimotion.log_exception(e, current_user)
|
|
227
|
+
{ error: e.message }
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Sibling namespace of :create_ai_klass — MUST stay above the route_param :id
|
|
232
|
+
# blocks below, otherwise POST /ai_fill_data falls through to the element
|
|
233
|
+
# instance route and Element.find(nil) is raised.
|
|
234
|
+
namespace :ai_fill_data do
|
|
235
|
+
desc "Auto-fill a generic element's data values from a document using AI"
|
|
236
|
+
params do
|
|
237
|
+
use :ai_fill_element_data_params
|
|
238
|
+
end
|
|
239
|
+
post do
|
|
240
|
+
msg = ai_fill_element_data(params, current_user)
|
|
241
|
+
if msg[:status] == 'success'
|
|
242
|
+
{ status: 'success', values: msg[:values], summary: msg[:summary] }
|
|
243
|
+
else
|
|
244
|
+
{ status: 'error', message: msg[:message] }
|
|
245
|
+
end
|
|
246
|
+
rescue StandardError => e
|
|
247
|
+
Labimotion.log_exception(e, current_user)
|
|
248
|
+
{ status: 'error', message: e.message }
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
215
252
|
namespace :update_element_klass do
|
|
216
253
|
desc 'update Generic Element Klass'
|
|
217
254
|
params do
|
|
@@ -59,6 +59,25 @@ module Labimotion
|
|
|
59
59
|
end
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
+
namespace :create_ai_klass do
|
|
63
|
+
desc 'create a Generic Segment Klass from an AI-generated template'
|
|
64
|
+
params do
|
|
65
|
+
use :create_ai_segment_klass_params
|
|
66
|
+
end
|
|
67
|
+
after_validation do
|
|
68
|
+
authenticate_admin!('segments')
|
|
69
|
+
@klass = fetch_klass('ElementKlass', params[:element_klass])
|
|
70
|
+
end
|
|
71
|
+
post do
|
|
72
|
+
msg = create_ai_segment_klass(current_user, params)
|
|
73
|
+
{ status: msg[:status], message: msg[:message],
|
|
74
|
+
klass: Labimotion::SegmentKlassEntity.represent(Labimotion::SegmentKlass.all) }
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
Labimotion.log_exception(e, current_user)
|
|
77
|
+
{ error: e.message }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
62
81
|
namespace :update_segment_klass do
|
|
63
82
|
desc 'update Generic Segment Klass'
|
|
64
83
|
params do
|
|
@@ -58,6 +58,104 @@ module Labimotion
|
|
|
58
58
|
raise e
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
+
# Create a new (inactive) dataset klass whose properties template is
|
|
62
|
+
# generated by an LLM from a CHMO ontology term plus optional description,
|
|
63
|
+
# reference links and uploaded files. The admin reviews/edits the generated
|
|
64
|
+
# template in the designer and activates it (human-in-the-loop).
|
|
65
|
+
def create_ai_dataset_klass(params, current_user)
|
|
66
|
+
ols_term_id = params[:ols_term_id].to_s.split('|').first.to_s.strip
|
|
67
|
+
raise 'An ontology term (CHMO) is required' if ols_term_id.blank?
|
|
68
|
+
|
|
69
|
+
if Labimotion::DatasetKlass.find_by(ols_term_id: ols_term_id).present?
|
|
70
|
+
return { status: 'error',
|
|
71
|
+
message: "A dataset template already exists for #{ols_term_id}. Edit it in the designer instead." }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
if Array(params[:files]).size > Labimotion::AiTemplate::MAX_FILES
|
|
75
|
+
return { status: 'error', message: "Too many files (max #{Labimotion::AiTemplate::MAX_FILES})." }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
overrides = ai_user_overrides(current_user)
|
|
79
|
+
ai = Labimotion::AiTemplate.generate(
|
|
80
|
+
ols_term_id: params[:ols_term_id],
|
|
81
|
+
desc: params[:desc],
|
|
82
|
+
cols: params[:cols],
|
|
83
|
+
references: params[:references],
|
|
84
|
+
files: params[:files],
|
|
85
|
+
**overrides
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
uuid = SecureRandom.uuid
|
|
89
|
+
label = ai['label'].presence ||
|
|
90
|
+
params[:ols_term_id].to_s.split('|').last.to_s.strip.presence ||
|
|
91
|
+
'AI dataset template'
|
|
92
|
+
# property-base schema requires pkg, uuid, klass, layers, version, identifier.
|
|
93
|
+
properties_template = {
|
|
94
|
+
'uuid' => uuid,
|
|
95
|
+
'klass' => 'DatasetKlass',
|
|
96
|
+
'pkg' => Labimotion::Utils.pkg(nil),
|
|
97
|
+
'version' => '1.0.0',
|
|
98
|
+
'identifier' => uuid,
|
|
99
|
+
'layers' => ai['layers'],
|
|
100
|
+
'select_options' => ai['select_options'],
|
|
101
|
+
'metadata' => ai['metadata']
|
|
102
|
+
}
|
|
103
|
+
attributes = {
|
|
104
|
+
'uuid' => uuid,
|
|
105
|
+
'label' => label,
|
|
106
|
+
'desc' => params[:desc].presence || ai['label'].presence,
|
|
107
|
+
'ols_term_id' => ols_term_id,
|
|
108
|
+
'place' => ((Labimotion::DatasetKlass.all.length * 10) || 0) + 10,
|
|
109
|
+
'is_active' => false,
|
|
110
|
+
'released_at' => DateTime.now,
|
|
111
|
+
'properties_template' => properties_template,
|
|
112
|
+
'properties_release' => properties_template,
|
|
113
|
+
'created_by' => current_user.id
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
ds = Labimotion::DatasetKlass.create!(attributes)
|
|
117
|
+
ds.create_klasses_revision(current_user)
|
|
118
|
+
{ status: 'success',
|
|
119
|
+
message: "The AI dataset template [#{label}] has been created as inactive. Review and activate it in the designer." }
|
|
120
|
+
rescue StandardError => e
|
|
121
|
+
Labimotion.log_exception(e, current_user)
|
|
122
|
+
{ status: 'error', message: e.message }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Refine an existing dataset template with AI and return the revised template
|
|
126
|
+
# (label, layers, select_options) plus a one-line summary of the change.
|
|
127
|
+
# Nothing is persisted — the admin applies the result into the designer's
|
|
128
|
+
# working copy and saves it there (human-in-the-loop, mirrors the create flow).
|
|
129
|
+
def refine_ai_dataset_klass(params, current_user)
|
|
130
|
+
instruction = params[:instruction].to_s.strip
|
|
131
|
+
raise 'An instruction is required' if instruction.blank?
|
|
132
|
+
|
|
133
|
+
current = {
|
|
134
|
+
'label' => params[:label],
|
|
135
|
+
'layers' => params[:layers] || {},
|
|
136
|
+
'select_options' => params[:select_options] || {}
|
|
137
|
+
}
|
|
138
|
+
overrides = ai_user_overrides(current_user)
|
|
139
|
+
ai = Labimotion::AiTemplate.refine(
|
|
140
|
+
current: current,
|
|
141
|
+
instruction: instruction,
|
|
142
|
+
ols_term_id: ai_term_with_label(params[:ols_term_id]),
|
|
143
|
+
history: params[:history],
|
|
144
|
+
cols: params[:cols],
|
|
145
|
+
**overrides
|
|
146
|
+
)
|
|
147
|
+
{
|
|
148
|
+
status: 'success',
|
|
149
|
+
label: ai['label'].presence || params[:label],
|
|
150
|
+
layers: ai['layers'],
|
|
151
|
+
select_options: ai['select_options'],
|
|
152
|
+
summary: ai['summary']
|
|
153
|
+
}
|
|
154
|
+
rescue StandardError => e
|
|
155
|
+
Labimotion.log_exception(e, current_user)
|
|
156
|
+
{ status: 'error', message: e.message }
|
|
157
|
+
end
|
|
158
|
+
|
|
61
159
|
def find_best_match_template(ols_term_id)
|
|
62
160
|
result = Labimotion::TemplateMatcher.find_best_match(ols_term_id)
|
|
63
161
|
if result[:template]
|
|
@@ -69,6 +167,21 @@ module Labimotion
|
|
|
69
167
|
|
|
70
168
|
private
|
|
71
169
|
|
|
170
|
+
# Pair the CHMO id with its human label ("CHMO:0000470 | mass spectrometry")
|
|
171
|
+
# by resolving the label from ols_terms, so the AI refine scope guard can name
|
|
172
|
+
# the method rather than show a bare id. Falls back to the raw value when the
|
|
173
|
+
# term is missing/unknown; never raises (a failed lookup must not block refine).
|
|
174
|
+
def ai_term_with_label(raw)
|
|
175
|
+
id = raw.to_s.split('|').first.to_s.strip
|
|
176
|
+
return raw.to_s if id.blank? || !defined?(OlsTerm)
|
|
177
|
+
|
|
178
|
+
label = OlsTerm.find_by(term_id: id)&.label
|
|
179
|
+
label.present? ? "#{id} | #{label}" : raw.to_s
|
|
180
|
+
rescue StandardError => e
|
|
181
|
+
Labimotion.log_exception(e)
|
|
182
|
+
raw.to_s
|
|
183
|
+
end
|
|
184
|
+
|
|
72
185
|
def build_template_response(template, match_type, info_messages)
|
|
73
186
|
response = {
|
|
74
187
|
error: '',
|
|
@@ -54,6 +54,184 @@ module Labimotion
|
|
|
54
54
|
raise e
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# Create a new (inactive) element klass whose properties template is
|
|
58
|
+
# generated by an LLM from a user-provided name/label plus optional
|
|
59
|
+
# description, reference links and uploaded files. The admin reviews/edits
|
|
60
|
+
# the generated template in the designer and activates it (human-in-the-loop).
|
|
61
|
+
def create_ai_element_klass(params, current_user)
|
|
62
|
+
name = params[:name]
|
|
63
|
+
label = params[:label]
|
|
64
|
+
if Labimotion::ElementKlass.find_by(name: name).present?
|
|
65
|
+
return { status: 'error', message: "An element named [#{name}] already exists." }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
if Array(params[:files]).size > Labimotion::AiTemplate::MAX_FILES
|
|
69
|
+
return { status: 'error', message: "Too many files (max #{Labimotion::AiTemplate::MAX_FILES})." }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
overrides = ai_user_overrides(current_user)
|
|
73
|
+
ai = Labimotion::AiTemplate.generate(
|
|
74
|
+
kind: 'element',
|
|
75
|
+
subject: "#{params[:label]} (#{params[:name]})",
|
|
76
|
+
desc: params[:desc],
|
|
77
|
+
cols: params[:cols],
|
|
78
|
+
references: params[:references],
|
|
79
|
+
files: params[:files],
|
|
80
|
+
**overrides
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
uuid = SecureRandom.uuid
|
|
84
|
+
# property-base schema requires pkg, uuid, klass, layers, version, identifier.
|
|
85
|
+
properties_template = {
|
|
86
|
+
'uuid' => uuid,
|
|
87
|
+
'klass' => 'ElementKlass',
|
|
88
|
+
'pkg' => Labimotion::Utils.pkg(nil),
|
|
89
|
+
'version' => '1.0.0',
|
|
90
|
+
'identifier' => uuid,
|
|
91
|
+
'layers' => ai['layers'],
|
|
92
|
+
'select_options' => ai['select_options'],
|
|
93
|
+
'metadata' => ai['metadata']
|
|
94
|
+
}
|
|
95
|
+
attributes = {
|
|
96
|
+
'name' => name,
|
|
97
|
+
'label' => label,
|
|
98
|
+
'klass_prefix' => params[:klass_prefix],
|
|
99
|
+
'icon_name' => params[:icon_name],
|
|
100
|
+
'desc' => params[:desc].presence || ai['label'].presence,
|
|
101
|
+
'is_active' => false,
|
|
102
|
+
'uuid' => uuid,
|
|
103
|
+
'released_at' => DateTime.now,
|
|
104
|
+
'properties_template' => properties_template,
|
|
105
|
+
'properties_release' => properties_template,
|
|
106
|
+
'created_by' => current_user.id
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
new_klass = Labimotion::ElementKlass.create!(attributes)
|
|
110
|
+
new_klass.reload
|
|
111
|
+
new_klass.create_klasses_revision(current_user)
|
|
112
|
+
klass_names_file = Labimotion::KLASSES_JSON # Rails.root.join('app/packs/klasses.json')
|
|
113
|
+
klasses = Labimotion::ElementKlass.where(is_active: true)&.pluck(:name) || []
|
|
114
|
+
File.write(klass_names_file, klasses)
|
|
115
|
+
{ status: 'success',
|
|
116
|
+
message: "The AI element template [#{label}] has been created as inactive. Review and activate it in the designer." }
|
|
117
|
+
rescue StandardError => e
|
|
118
|
+
Labimotion.log_exception(e, current_user)
|
|
119
|
+
{ status: 'error', message: e.message }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# AI auto-fill of a generic element instance's DATA VALUES from a document
|
|
123
|
+
# (an existing attachment, an analysis, or freshly uploaded files). Reads the
|
|
124
|
+
# source text, asks the LLM to extract values for the element's OWN template
|
|
125
|
+
# fields, validates them, and returns them for the client to merge into the
|
|
126
|
+
# working copy for human review. Nothing is persisted here.
|
|
127
|
+
#
|
|
128
|
+
# Authorization: the caller must be able to READ the element, and the source
|
|
129
|
+
# must BELONG to that element (see the ownership checks below) — this endpoint
|
|
130
|
+
# reads files by id, so it must never read an Attachment/Container the user
|
|
131
|
+
# does not already have access to through this element.
|
|
132
|
+
def ai_fill_element_data(params, current_user)
|
|
133
|
+
element = Labimotion::Element.find(params[:element_id])
|
|
134
|
+
error!('401 Unauthorized', 401) unless ElementPolicy.new(current_user, element).read?
|
|
135
|
+
|
|
136
|
+
text = ai_fill_source_text(element, params)
|
|
137
|
+
overrides = ai_user_overrides(current_user)
|
|
138
|
+
ai = Labimotion::AiTemplate.fill(
|
|
139
|
+
properties: element.properties,
|
|
140
|
+
context_text: text,
|
|
141
|
+
**overrides
|
|
142
|
+
)
|
|
143
|
+
{ status: 'success', values: ai['values'], summary: ai['summary'] }
|
|
144
|
+
rescue StandardError => e
|
|
145
|
+
Labimotion.log_exception(e, current_user)
|
|
146
|
+
{ status: 'error', message: e.message }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Resolve the requested source into plain text, enforcing ownership first.
|
|
150
|
+
def ai_fill_source_text(element, params)
|
|
151
|
+
case params[:source]
|
|
152
|
+
when 'attachment'
|
|
153
|
+
ai_fill_attachment_text(element, params[:attachment_id])
|
|
154
|
+
when 'analysis'
|
|
155
|
+
ai_fill_analysis_text(element, params[:container_id])
|
|
156
|
+
when 'upload'
|
|
157
|
+
ai_fill_upload_text(params[:files])
|
|
158
|
+
else
|
|
159
|
+
error!('400 Bad Request', 400)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The attachment must be one of the element's own attachments OR an attachment
|
|
164
|
+
# of one of the element's analysis DATASET containers. Only then is it read.
|
|
165
|
+
def ai_fill_attachment_text(element, attachment_id)
|
|
166
|
+
error!('404 Not Found', 404) if attachment_id.nil?
|
|
167
|
+
error!('404 Not Found', 404) unless ai_fill_element_attachment_ids(element).include?(attachment_id.to_i)
|
|
168
|
+
|
|
169
|
+
att = Attachment.find(attachment_id)
|
|
170
|
+
Labimotion::FileExtractor.extract_bytes(att.filename, att.read_file)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# The container id must be one of the element's own analysis containers. Then
|
|
174
|
+
# concatenate the extracted text of every attachment on that analysis's dataset
|
|
175
|
+
# children, each prefixed "### <filename>", with the analysis name/description
|
|
176
|
+
# prepended as context. Cap at AiTemplate::MAX_FILES; skip unreadable files.
|
|
177
|
+
def ai_fill_analysis_text(element, container_id)
|
|
178
|
+
error!('404 Not Found', 404) if container_id.nil?
|
|
179
|
+
analysis = element.analyses.detect { |c| c.id == container_id.to_i }
|
|
180
|
+
error!('404 Not Found', 404) if analysis.nil?
|
|
181
|
+
|
|
182
|
+
parts = []
|
|
183
|
+
context = [analysis.name.presence, analysis.description.presence].compact.join(' — ')
|
|
184
|
+
parts << "Analysis: #{context}" if context.present?
|
|
185
|
+
|
|
186
|
+
ai_fill_analysis_attachments(analysis).first(Labimotion::AiTemplate::MAX_FILES).each do |att|
|
|
187
|
+
content = ai_fill_extract_attachment(att)
|
|
188
|
+
next if content.to_s.strip.empty?
|
|
189
|
+
|
|
190
|
+
parts << "### #{att.filename}\n#{content}"
|
|
191
|
+
end
|
|
192
|
+
parts.join("\n\n")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def ai_fill_upload_text(files)
|
|
196
|
+
parts = Array(files).first(Labimotion::AiTemplate::MAX_FILES).map do |file|
|
|
197
|
+
name = file[:filename] || file['filename']
|
|
198
|
+
next if name.blank?
|
|
199
|
+
|
|
200
|
+
content = Labimotion::FileExtractor.extract(name, file[:content_base64] || file['content_base64'])
|
|
201
|
+
next if content.to_s.strip.empty?
|
|
202
|
+
|
|
203
|
+
"### #{name}\n#{content}"
|
|
204
|
+
end.compact
|
|
205
|
+
parts.join("\n\n")
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Ids of attachments this element may expose to the AI fill: its own
|
|
209
|
+
# attachments plus the attachments of its analysis dataset containers.
|
|
210
|
+
def ai_fill_element_attachment_ids(element)
|
|
211
|
+
own = element.attachments.pluck(:id)
|
|
212
|
+
dataset_ids = ai_fill_dataset_containers(element).map(&:id)
|
|
213
|
+
dataset_att = dataset_ids.empty? ? [] : Attachment.where(attachable_type: 'Container', attachable_id: dataset_ids).pluck(:id)
|
|
214
|
+
(own + dataset_att).uniq
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Dataset containers under all of the element's analysis containers.
|
|
218
|
+
def ai_fill_dataset_containers(element)
|
|
219
|
+
element.analyses.flat_map { |analysis| analysis.children.where(container_type: 'dataset').to_a }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def ai_fill_analysis_attachments(analysis)
|
|
223
|
+
analysis.children.where(container_type: 'dataset').flat_map do |dataset|
|
|
224
|
+
Attachment.where(attachable_type: 'Container', attachable_id: dataset.id).to_a
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def ai_fill_extract_attachment(att)
|
|
229
|
+
Labimotion::FileExtractor.extract_bytes(att.filename, att.read_file)
|
|
230
|
+
rescue StandardError => e
|
|
231
|
+
Labimotion.log_exception(e)
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
|
|
57
235
|
def update_element_klass(current_user, params)
|
|
58
236
|
place = params[:place] || 100
|
|
59
237
|
begin
|
|
@@ -109,6 +109,68 @@ module Labimotion
|
|
|
109
109
|
raise e
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
+
# Per-user AI overrides (model + API key) resolved from the host app's user
|
|
113
|
+
# profile. Returns {} when the host doesn't expose them, so AiTemplate falls
|
|
114
|
+
# back to the server-wide yml/ENV configuration. Guarded with respond_to? to
|
|
115
|
+
# keep the gem decoupled from the host's Profile implementation. Shared by the
|
|
116
|
+
# dataset / element / segment AI helpers.
|
|
117
|
+
def ai_user_overrides(current_user)
|
|
118
|
+
profile = current_user.respond_to?(:profile) ? current_user.profile : nil
|
|
119
|
+
return {} if profile.nil?
|
|
120
|
+
|
|
121
|
+
model = (profile.labimotion_ai_model if profile.respond_to?(:labimotion_ai_model))
|
|
122
|
+
api_key = (profile.labimotion_ai_api_key if profile.respond_to?(:labimotion_ai_api_key))
|
|
123
|
+
model = ai_shared_key_model(model) if api_key.blank?
|
|
124
|
+
|
|
125
|
+
{ model: model, api_key: api_key }.merge(ai_provider_overrides(profile, api_key)).compact
|
|
126
|
+
rescue StandardError => e
|
|
127
|
+
Labimotion.log_exception(e, current_user)
|
|
128
|
+
{}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Personal provider endpoint (base_url/api_path) — honored only ALONGSIDE a
|
|
132
|
+
# personal key, so the shared server key is never sent to a user-supplied URL.
|
|
133
|
+
# The base_url itself is SSRF-validated later, in AiTemplate.
|
|
134
|
+
def ai_provider_overrides(profile, api_key)
|
|
135
|
+
return {} if api_key.blank?
|
|
136
|
+
|
|
137
|
+
{
|
|
138
|
+
base_url: (profile.labimotion_ai_base_url if profile.respond_to?(:labimotion_ai_base_url)),
|
|
139
|
+
api_path: (profile.labimotion_ai_api_path if profile.respond_to?(:labimotion_ai_api_path))
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# On the SHARED server key (user has no personal key), keep the user's model
|
|
144
|
+
# only if the admin allowed it; otherwise return nil so AiTemplate falls back
|
|
145
|
+
# to the server default. Returns the model unchanged when no allowlist is
|
|
146
|
+
# configured (fail open). Guards an off-list model being billed to the shared key.
|
|
147
|
+
def ai_shared_key_model(model)
|
|
148
|
+
return model if model.blank?
|
|
149
|
+
|
|
150
|
+
allow = ai_server_model_allowlist
|
|
151
|
+
allow.any? && !allow.include?(model.to_s) ? nil : model
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Model ids the server permits on the SHARED key: the admin pick-list
|
|
155
|
+
# (:models) plus the server default (:model / KI_TOOLBOX_MODEL), read from the
|
|
156
|
+
# host config the same way Labimotion::AiTemplate does. Empty when the admin
|
|
157
|
+
# configured neither — the caller then skips the clamp (fail open), matching
|
|
158
|
+
# the gem's decoupled, non-blocking handling of overrides.
|
|
159
|
+
def ai_server_model_allowlist
|
|
160
|
+
cfg = Rails.configuration.labimotion_ai || {}
|
|
161
|
+
list = cfg[:models].is_a?(Array) ? cfg[:models] : []
|
|
162
|
+
ids = list.map { |entry| ai_model_entry_id(entry) }
|
|
163
|
+
(ids << cfg[:model].to_s << ENV['KI_TOOLBOX_MODEL'].to_s).reject(&:blank?).uniq
|
|
164
|
+
rescue StandardError
|
|
165
|
+
[]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def ai_model_entry_id(entry)
|
|
169
|
+
return entry.to_s unless entry.is_a?(Hash)
|
|
170
|
+
|
|
171
|
+
(entry[:id] || entry['id']).to_s
|
|
172
|
+
end
|
|
173
|
+
|
|
112
174
|
def deactivate_klass(params)
|
|
113
175
|
klz = fetch_klass(params[:klass], params[:id])
|
|
114
176
|
authorize_klass!(klz, :deactivate)
|
|
@@ -35,6 +35,74 @@ module Labimotion
|
|
|
35
35
|
optional :properties_template, type: Hash, desc: 'Element Klass properties template'
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
params :create_ai_dataset_klass_params do
|
|
39
|
+
requires :ols_term_id, type: String, desc: 'CHMO ontology term (e.g. "CHMO:0000470 | mass spectrometry (MS)")'
|
|
40
|
+
optional :desc, type: String, desc: 'Description / intent of the new dataset template'
|
|
41
|
+
optional :cols, type: Integer, values: 1..6, default: 1, desc: 'Default columns per row for generated layers'
|
|
42
|
+
optional :references, type: Array[String], desc: 'Reference links (publications / standards)'
|
|
43
|
+
optional :files, type: Array, desc: 'Reference files for context' do
|
|
44
|
+
optional :filename, type: String, desc: 'File name'
|
|
45
|
+
optional :content, type: String, desc: 'Client-read plaintext (legacy, truncated)'
|
|
46
|
+
optional :content_base64, type: String, desc: 'Base64-encoded raw file bytes (PDF/Excel/text — preferred)'
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
params :create_ai_element_klass_params do
|
|
51
|
+
requires :name, type: String, desc: 'Element Klass Name'
|
|
52
|
+
requires :label, type: String, desc: 'Element Klass Label'
|
|
53
|
+
requires :klass_prefix, type: String, desc: 'Element Klass Short Label Prefix'
|
|
54
|
+
optional :icon_name, type: String, desc: 'Element Klass Icon Name'
|
|
55
|
+
optional :desc, type: String, desc: 'Description / intent of the new element template'
|
|
56
|
+
optional :cols, type: Integer, values: 1..6, default: 1, desc: 'Default columns per row for generated layers'
|
|
57
|
+
optional :references, type: Array[String], desc: 'Reference links (publications / standards)'
|
|
58
|
+
optional :files, type: Array, desc: 'Reference files for context' do
|
|
59
|
+
optional :filename, type: String, desc: 'File name'
|
|
60
|
+
optional :content, type: String, desc: 'Client-read plaintext (legacy, truncated)'
|
|
61
|
+
optional :content_base64, type: String, desc: 'Base64-encoded raw file bytes (PDF/Excel/text — preferred)'
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# AI auto-fill of a generic element instance's DATA VALUES from a document.
|
|
66
|
+
# `files` mirrors the create_ai_*_klass_params file block (source == 'upload').
|
|
67
|
+
params :ai_fill_element_data_params do
|
|
68
|
+
requires :element_id, type: Integer, desc: 'Generic element id whose data values to fill'
|
|
69
|
+
requires :source, type: String, values: %w[attachment analysis upload], desc: 'Value source'
|
|
70
|
+
optional :attachment_id, type: Integer, desc: 'Attachment id (required when source == attachment)'
|
|
71
|
+
optional :container_id, type: Integer, desc: 'Analysis container id (required when source == analysis)'
|
|
72
|
+
optional :files, type: Array, desc: 'Uploaded files (source == upload)' do
|
|
73
|
+
optional :filename, type: String, desc: 'File name'
|
|
74
|
+
optional :content, type: String, desc: 'Client-read plaintext (legacy, truncated)'
|
|
75
|
+
optional :content_base64, type: String, desc: 'Base64-encoded raw file bytes (PDF/Excel/text — preferred)'
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
params :create_ai_segment_klass_params do
|
|
80
|
+
requires :label, type: String, desc: 'Segment Klass Label'
|
|
81
|
+
requires :element_klass, type: Integer, desc: 'Parent Element Klass Id'
|
|
82
|
+
optional :metadata, type: Hash, desc: 'Klass metadata (segment render kind, e.g. { is_properties })'
|
|
83
|
+
optional :desc, type: String, desc: 'Description / intent of the new segment template'
|
|
84
|
+
optional :cols, type: Integer, values: 1..6, default: 1, desc: 'Default columns per row for generated layers'
|
|
85
|
+
optional :references, type: Array[String], desc: 'Reference links (publications / standards)'
|
|
86
|
+
optional :files, type: Array, desc: 'Reference files for context' do
|
|
87
|
+
optional :filename, type: String, desc: 'File name'
|
|
88
|
+
optional :content, type: String, desc: 'Client-read plaintext (legacy, truncated)'
|
|
89
|
+
optional :content_base64, type: String, desc: 'Base64-encoded raw file bytes (PDF/Excel/text — preferred)'
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
params :refine_ai_dataset_klass_params do
|
|
94
|
+
requires :instruction, type: String, desc: 'Natural-language change to apply to the template'
|
|
95
|
+
optional :label, type: String, desc: 'Current template label'
|
|
96
|
+
optional :ols_term_id, type: String, desc: 'CHMO ontology term (context only)'
|
|
97
|
+
optional :cols, type: Integer, values: 1..6, default: 1, desc: 'Fallback columns per row when a layer omits its own'
|
|
98
|
+
optional :layers, type: Hash, desc: 'Current template layers'
|
|
99
|
+
optional :select_options, type: Hash, desc: 'Current template select options'
|
|
100
|
+
optional :history, type: Array, desc: 'Prior chat turns for continuity' do
|
|
101
|
+
optional :role, type: String, desc: 'user | assistant'
|
|
102
|
+
optional :content, type: String, desc: 'Message content'
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
38
106
|
params :update_element_klass_params do
|
|
39
107
|
requires :id, type: Integer, desc: 'Element Klass ID'
|
|
40
108
|
optional :label, type: String, desc: 'Element Klass Label'
|
|
@@ -54,6 +54,68 @@ module Labimotion
|
|
|
54
54
|
raise e
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# Create a new (inactive) segment klass whose properties template is
|
|
58
|
+
# generated by an LLM from a user-provided label plus optional description,
|
|
59
|
+
# reference links and uploaded files. The parent ElementKlass must be set on
|
|
60
|
+
# @klass by the API (after_validation), mirroring create_segment_klass. The
|
|
61
|
+
# admin reviews/edits the generated template in the designer and activates it.
|
|
62
|
+
def create_ai_segment_klass(current_user, params)
|
|
63
|
+
return { status: 'error', message: 'A parent element class is required.' } if @klass.nil?
|
|
64
|
+
|
|
65
|
+
if Array(params[:files]).size > Labimotion::AiTemplate::MAX_FILES
|
|
66
|
+
return { status: 'error', message: "Too many files (max #{Labimotion::AiTemplate::MAX_FILES})." }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
overrides = ai_user_overrides(current_user)
|
|
70
|
+
ai = Labimotion::AiTemplate.generate(
|
|
71
|
+
kind: 'segment',
|
|
72
|
+
subject: params[:label],
|
|
73
|
+
desc: params[:desc],
|
|
74
|
+
cols: params[:cols],
|
|
75
|
+
references: params[:references],
|
|
76
|
+
files: params[:files],
|
|
77
|
+
**overrides
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
uuid = SecureRandom.uuid
|
|
81
|
+
# property-base schema requires pkg, uuid, klass, layers, version, identifier.
|
|
82
|
+
properties_template = {
|
|
83
|
+
'uuid' => uuid,
|
|
84
|
+
'klass' => 'SegmentKlass',
|
|
85
|
+
'pkg' => Labimotion::Utils.pkg(nil),
|
|
86
|
+
'version' => '1.0.0',
|
|
87
|
+
'identifier' => uuid,
|
|
88
|
+
'layers' => ai['layers'],
|
|
89
|
+
'select_options' => ai['select_options'],
|
|
90
|
+
'metadata' => ai['metadata']
|
|
91
|
+
}
|
|
92
|
+
attributes = {
|
|
93
|
+
'label' => params[:label],
|
|
94
|
+
'desc' => params[:desc].presence || ai['label'].presence,
|
|
95
|
+
'element_klass' => @klass,
|
|
96
|
+
'place' => 100,
|
|
97
|
+
'is_active' => false,
|
|
98
|
+
'uuid' => uuid,
|
|
99
|
+
'released_at' => DateTime.now,
|
|
100
|
+
'properties_template' => properties_template,
|
|
101
|
+
'properties_release' => properties_template,
|
|
102
|
+
'created_by' => current_user.id
|
|
103
|
+
}
|
|
104
|
+
# Klass-level segment metadata (render kind: own tab vs the host element's
|
|
105
|
+
# Properties tab), mirroring the non-AI create_segment_klass which persists
|
|
106
|
+
# params[:metadata] via declared. Absent -> leave the column default.
|
|
107
|
+
attributes['metadata'] = params[:metadata] if params[:metadata].present?
|
|
108
|
+
|
|
109
|
+
klass = Labimotion::SegmentKlass.create!(attributes)
|
|
110
|
+
klass.reload
|
|
111
|
+
klass.create_klasses_revision(current_user)
|
|
112
|
+
{ status: 'success',
|
|
113
|
+
message: "The AI segment template [#{params[:label]}] has been created as inactive. Review and activate it in the designer." }
|
|
114
|
+
rescue StandardError => e
|
|
115
|
+
Labimotion.log_exception(e, current_user)
|
|
116
|
+
{ status: 'error', message: e.message }
|
|
117
|
+
end
|
|
118
|
+
|
|
57
119
|
def update_segment_klass(current_user, params)
|
|
58
120
|
segment = fetch_klass('SegmentKlass', params[:id])
|
|
59
121
|
authorize_klass!(segment, :write)
|