labimotion 2.4.0.rc5 → 2.4.0.rc7
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 +57 -0
- data/lib/labimotion/apis/labimotion_ai_api.rb +239 -0
- data/lib/labimotion/apis/labimotion_api.rb +2 -0
- data/lib/labimotion/apis/ontology_root_api.rb +74 -0
- data/lib/labimotion/apis/segment_api.rb +19 -0
- data/lib/labimotion/conf.rb +5 -0
- data/lib/labimotion/constants.rb +11 -0
- data/lib/labimotion/helpers/dataset_helpers.rb +113 -0
- data/lib/labimotion/helpers/element_helpers.rb +228 -0
- data/lib/labimotion/helpers/generic_helpers.rb +62 -0
- data/lib/labimotion/helpers/param_helpers.rb +69 -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_models.rb +201 -0
- data/lib/labimotion/libs/ai_template.rb +1842 -0
- data/lib/labimotion/libs/file_extractor.rb +210 -0
- data/lib/labimotion/libs/ontology_store.rb +226 -0
- data/lib/labimotion/libs/ontology_terms.rb +227 -0
- data/lib/labimotion/models/concerns/profile_ai_settings.rb +116 -0
- data/lib/labimotion/version.rb +1 -1
- data/lib/labimotion.rb +9 -0
- metadata +25 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: baacaf6374ee395ea0178ebb5d0ecf6d3fef5a0f37bb17852e4742ac6442ccee
|
|
4
|
+
data.tar.gz: 01ab63f69e28a3d990480c9dd6eb747f26bc7cc727c57ddcb94034a8e987937e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5463d7bffb59f2ef5ef5e664a8ac97fb28557965942a0b182ca2834ec33cc0d81a1ef275799f9b0b7cadb93c1851eff0dbf429c115c863d336c92e91d68395fb
|
|
7
|
+
data.tar.gz: b83156927689da21c02e5a632d526fd1eafb9cd93695ef407fc88cf6b7d65a49593519cb36b2ed9c93afffcb935bbfe3ad274950e549839f6ca40a605a4ec82f
|
|
@@ -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,63 @@ 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
|
+
# Auto-fill is the one AI action ordinary users reach — the designer's
|
|
236
|
+
# create/refine routes are admin-only — so it carries its own whitelist,
|
|
237
|
+
# supplied by the host: the genericElement AI list narrowed by
|
|
238
|
+
# :fill_uids. Reached through the host the same way this API already
|
|
239
|
+
# reaches ElementPolicy, and CLOSED when the host provides no such
|
|
240
|
+
# policy, matching how every other gate in this feature defaults.
|
|
241
|
+
# NameError, not defined?: under Zeitwerk `defined?` is nil until the
|
|
242
|
+
# constant has actually been referenced, so testing it would refuse the
|
|
243
|
+
# first request after every boot. Referencing it triggers the autoload;
|
|
244
|
+
# a host that genuinely ships no such policy raises, and that is a
|
|
245
|
+
# refusal too.
|
|
246
|
+
before do
|
|
247
|
+
allowed = begin
|
|
248
|
+
LabimotionAiAccess.fill_enabled_for?(current_user)
|
|
249
|
+
rescue NameError
|
|
250
|
+
false
|
|
251
|
+
end
|
|
252
|
+
error!({ status: 'error', message: 'AI auto-fill is not enabled for this account.' }, 403) unless allowed
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
desc "Auto-fill a generic element's data values from a document using AI"
|
|
256
|
+
params do
|
|
257
|
+
use :ai_fill_element_data_params
|
|
258
|
+
end
|
|
259
|
+
post do
|
|
260
|
+
msg = ai_fill_element_data(params, current_user)
|
|
261
|
+
if msg[:status] == 'success'
|
|
262
|
+
{ status: 'success', values: msg[:values], summary: msg[:summary] }
|
|
263
|
+
else
|
|
264
|
+
{ status: 'error', message: msg[:message] }
|
|
265
|
+
end
|
|
266
|
+
rescue StandardError => e
|
|
267
|
+
Labimotion.log_exception(e, current_user)
|
|
268
|
+
{ status: 'error', message: e.message }
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
215
272
|
namespace :update_element_klass do
|
|
216
273
|
desc 'update Generic Element Klass'
|
|
217
274
|
params do
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'labimotion/version'
|
|
4
|
+
|
|
5
|
+
module Labimotion
|
|
6
|
+
# Per-user LabIMotion AI settings: the model, an optional personal API key and
|
|
7
|
+
# an optional provider endpoint of one's own.
|
|
8
|
+
#
|
|
9
|
+
# These live on the HOST's user profile rather than in a gem table, because the
|
|
10
|
+
# credential belongs to the person, not to LabIMotion — so the routes stay
|
|
11
|
+
# under /profiles and reach the host's Profile through current_user, the same
|
|
12
|
+
# way the rest of this gem reaches ElementPolicy and Matrice.
|
|
13
|
+
#
|
|
14
|
+
# The API key is stored encrypted and never returned; a read only reports
|
|
15
|
+
# whether one is set.
|
|
16
|
+
class LabimotionAiAPI < Grape::API
|
|
17
|
+
# Fallback model choices when the server config (config/labimotion_ai.yml
|
|
18
|
+
# :models) lists none. A representative subset of the chat-capable KI-Toolbox
|
|
19
|
+
# models (ki-toolbox.scc.kit.edu); the full list lives in the yml.
|
|
20
|
+
DEFAULT_MODELS = %w[
|
|
21
|
+
azure.gpt-4.1-mini
|
|
22
|
+
azure.gpt-4.1
|
|
23
|
+
azure.gpt-5
|
|
24
|
+
azure.gpt-5-mini
|
|
25
|
+
google.claude-sonnet-4.6
|
|
26
|
+
google.claude-haiku-4.5
|
|
27
|
+
google.gemini-2.5-pro
|
|
28
|
+
kit.gpt-oss-120b
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
# rubocop:disable Metrics/BlockLength
|
|
32
|
+
resource :profiles do
|
|
33
|
+
namespace :labimotion_ai do
|
|
34
|
+
# Per-user LabIMotion AI settings (model + personal API key) for the
|
|
35
|
+
# AI dataset-template feature. The API key is stored encrypted and is
|
|
36
|
+
# never returned — only whether one is set.
|
|
37
|
+
|
|
38
|
+
# These settings only exist to serve the AI integration, so they follow
|
|
39
|
+
# the same whitelist (matrices.configs :ai_uids). Users off the list get
|
|
40
|
+
# a 403 rather than a settings page they cannot act on.
|
|
41
|
+
before do
|
|
42
|
+
error!({ status: false, error: 'AI integration is not enabled for this account.' }, 403) unless
|
|
43
|
+
Matrice.ai_enabled_for_any?(current_user)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
helpers do
|
|
47
|
+
# Normalize a configured model entry (an id String, or a Hash with
|
|
48
|
+
# :id/:label) into { id:, label: }. Label falls back to the id.
|
|
49
|
+
def normalize_ai_model(entry)
|
|
50
|
+
if entry.respond_to?(:[]) && !entry.is_a?(String)
|
|
51
|
+
id = (entry[:id] || entry['id']).to_s
|
|
52
|
+
label = (entry[:label] || entry['label']).to_s
|
|
53
|
+
else
|
|
54
|
+
id = entry.to_s
|
|
55
|
+
label = ''
|
|
56
|
+
end
|
|
57
|
+
return nil if id.blank?
|
|
58
|
+
|
|
59
|
+
{ id: id, label: label.presence || id }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Add id (with a label) to models unless already present. Mutates models.
|
|
63
|
+
def append_ai_model(models, id)
|
|
64
|
+
return if id.blank? || models.any? { |m| m[:id] == id }
|
|
65
|
+
|
|
66
|
+
models << { id: id, label: id }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The admin-approved models [{ id:, label: }] for the SHARED server key:
|
|
70
|
+
# the configured :models pick-list (or the built-in default) plus the
|
|
71
|
+
# server default (:model). No per-user selection — safe to enforce.
|
|
72
|
+
def labimotion_ai_server_models
|
|
73
|
+
configured = Rails.configuration.respond_to?(:labimotion_ai) ? Rails.configuration.labimotion_ai : nil
|
|
74
|
+
models = Array(configured && configured[:models]).filter_map { |e| normalize_ai_model(e) }
|
|
75
|
+
models = Labimotion::LabimotionAiAPI::DEFAULT_MODELS.map { |id| { id: id, label: id } } if models.empty?
|
|
76
|
+
append_ai_model(models, (configured && configured[:model]).to_s)
|
|
77
|
+
models
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The selectable models as [{ id:, label: }]. Extends the server models
|
|
81
|
+
# with the user's saved model so their current selection is always valid.
|
|
82
|
+
def labimotion_ai_models
|
|
83
|
+
models = labimotion_ai_server_models
|
|
84
|
+
append_ai_model(models, current_user.profile.labimotion_ai_model.to_s)
|
|
85
|
+
models
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Model ids the SHARED server key permits — the server models plus the
|
|
89
|
+
# ENV default. Unlike available_models this excludes the user's own saved
|
|
90
|
+
# model, so it is safe to enforce a keyless user's selection against.
|
|
91
|
+
def labimotion_ai_admin_model_ids
|
|
92
|
+
ids = labimotion_ai_server_models.pluck(:id)
|
|
93
|
+
(ids << ENV['KI_TOOLBOX_MODEL'].to_s).compact_blank.uniq
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A well-formed https URL with a host. The deep SSRF check (host resolves
|
|
97
|
+
# to a public address) runs server-side in the gem at request time; this
|
|
98
|
+
# is only a fast format check for immediate UI feedback.
|
|
99
|
+
def https_endpoint?(url)
|
|
100
|
+
uri = URI.parse(url.to_s.strip)
|
|
101
|
+
uri.is_a?(URI::HTTPS) && uri.host.present?
|
|
102
|
+
rescue URI::InvalidURIError
|
|
103
|
+
false
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A personal provider endpoint is only usable with a personal key, and
|
|
107
|
+
# must be a valid https URL. Raises a 422 otherwise; no-op when unset.
|
|
108
|
+
def validate_ai_endpoint!(base_url_arg, will_have_key)
|
|
109
|
+
return if base_url_arg.blank?
|
|
110
|
+
|
|
111
|
+
unless will_have_key
|
|
112
|
+
error!(
|
|
113
|
+
{ status: false, error: 'Add your personal API key to use your own AI provider endpoint.' },
|
|
114
|
+
422
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
return if https_endpoint?(base_url_arg)
|
|
118
|
+
|
|
119
|
+
error!({ status: false, error: 'The AI provider endpoint must be a valid https:// URL.' }, 422)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
desc 'get the current user LabIMotion AI settings (never returns the key)'
|
|
124
|
+
get do
|
|
125
|
+
profile = current_user.profile
|
|
126
|
+
configured = Rails.configuration.respond_to?(:labimotion_ai) ? Rails.configuration.labimotion_ai : nil
|
|
127
|
+
{
|
|
128
|
+
model: profile.labimotion_ai_model,
|
|
129
|
+
api_key_set: profile.labimotion_ai_api_key?,
|
|
130
|
+
base_url: profile.labimotion_ai_base_url,
|
|
131
|
+
api_path: profile.labimotion_ai_api_path,
|
|
132
|
+
available_models: labimotion_ai_models,
|
|
133
|
+
default_model: (configured && configured[:model]).presence,
|
|
134
|
+
default_base_url: (configured && configured[:base_url]).presence,
|
|
135
|
+
server_api_key_set: (configured && configured[:api_key]).present?
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
desc 'update the current user LabIMotion AI settings'
|
|
140
|
+
params do
|
|
141
|
+
optional :model, type: String, desc: 'Selected model id'
|
|
142
|
+
optional :api_key, type: String, desc: 'Personal API key (stored encrypted)'
|
|
143
|
+
optional :clear_api_key, type: Boolean, default: false, desc: 'Remove the stored API key'
|
|
144
|
+
optional :base_url, type: String, desc: 'Personal AI provider base URL (https; needs a personal key)'
|
|
145
|
+
optional :api_path, type: String, desc: 'Personal AI provider chat-completions path'
|
|
146
|
+
optional :clear_endpoint, type: Boolean, default: false, desc: 'Remove the stored provider endpoint'
|
|
147
|
+
end
|
|
148
|
+
put do
|
|
149
|
+
profile = current_user.profile
|
|
150
|
+
api_key_arg =
|
|
151
|
+
if params[:clear_api_key]
|
|
152
|
+
'' # blank -> clear
|
|
153
|
+
elsif params[:api_key].present?
|
|
154
|
+
params[:api_key]
|
|
155
|
+
end
|
|
156
|
+
# nil -> keep the stored key
|
|
157
|
+
|
|
158
|
+
# Whether the user will hold a personal key after this save.
|
|
159
|
+
will_have_key =
|
|
160
|
+
if params[:clear_api_key]
|
|
161
|
+
false
|
|
162
|
+
elsif params[:api_key].present?
|
|
163
|
+
true
|
|
164
|
+
else
|
|
165
|
+
profile.labimotion_ai_api_key?
|
|
166
|
+
end
|
|
167
|
+
# A keyless user's AI calls run on the shared server key, so the chosen
|
|
168
|
+
# model must be one the admin approved. A user on their own key may pick
|
|
169
|
+
# any model. Enforce only when an allowlist is actually configured.
|
|
170
|
+
if params[:model].present? && !will_have_key
|
|
171
|
+
allowed = labimotion_ai_admin_model_ids
|
|
172
|
+
if allowed.any? && allowed.exclude?(params[:model])
|
|
173
|
+
error!(
|
|
174
|
+
{ status: false,
|
|
175
|
+
error: 'That model is not available on the shared server key. Pick a listed ' \
|
|
176
|
+
'model, or add your personal API key in My LabIMotion to use it.' },
|
|
177
|
+
422
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# base_url/api_path: '' clears (via clear_endpoint), a value sets, nil keeps.
|
|
183
|
+
base_url_arg = params[:clear_endpoint] ? '' : params[:base_url]
|
|
184
|
+
api_path_arg = params[:clear_endpoint] ? '' : params[:api_path]
|
|
185
|
+
validate_ai_endpoint!(base_url_arg, will_have_key)
|
|
186
|
+
|
|
187
|
+
summary = profile.update_labimotion_ai(
|
|
188
|
+
model: params[:model].presence, api_key: api_key_arg,
|
|
189
|
+
base_url: base_url_arg, api_path: api_path_arg
|
|
190
|
+
)
|
|
191
|
+
{
|
|
192
|
+
model: summary[:model],
|
|
193
|
+
api_key_set: summary[:api_key_set],
|
|
194
|
+
base_url: summary[:base_url],
|
|
195
|
+
api_path: summary[:api_path],
|
|
196
|
+
available_models: labimotion_ai_models
|
|
197
|
+
}
|
|
198
|
+
rescue StandardError => e
|
|
199
|
+
error!({ status: false, error: e.message }, 422)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
desc 'fetch the live model list from the AI provider with the user personal key'
|
|
203
|
+
post 'models' do
|
|
204
|
+
profile = current_user.profile
|
|
205
|
+
# Deliberately the PERSONAL key only: the shared server key must not be
|
|
206
|
+
# sent to a user-supplied provider URL, and a keyless user's model choice
|
|
207
|
+
# is restricted to labimotion_ai_admin_model_ids anyway.
|
|
208
|
+
result = Labimotion::AiModels.new(
|
|
209
|
+
api_key: profile.labimotion_ai_api_key,
|
|
210
|
+
base_url: profile.labimotion_ai_base_url,
|
|
211
|
+
api_path: profile.labimotion_ai_api_path
|
|
212
|
+
).call
|
|
213
|
+
{ status: true, models: result[:models], endpoint: result[:endpoint] }
|
|
214
|
+
rescue StandardError => e
|
|
215
|
+
error!({ status: false, error: e.message }, 422)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
desc 'test the current user LabIMotion AI connection (nothing is saved)'
|
|
219
|
+
post 'test' do
|
|
220
|
+
profile = current_user.profile
|
|
221
|
+
# Same values the runtime override path reads, so the test exercises the
|
|
222
|
+
# real config: base_url/api_path are honored by the gem only alongside a
|
|
223
|
+
# personal key, and a custom endpoint is SSRF-validated there.
|
|
224
|
+
result = Labimotion::AiTemplate.ping(
|
|
225
|
+
model: profile.labimotion_ai_model,
|
|
226
|
+
api_key: profile.labimotion_ai_api_key,
|
|
227
|
+
base_url: profile.labimotion_ai_base_url,
|
|
228
|
+
api_path: profile.labimotion_ai_api_path
|
|
229
|
+
)
|
|
230
|
+
{ status: true, ok: true, model: result['model'],
|
|
231
|
+
endpoint: result['endpoint'], ms: result['ms'] }
|
|
232
|
+
rescue StandardError => e
|
|
233
|
+
error!({ status: false, ok: false, error: e.message }, 422)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
# rubocop:enable Metrics/BlockLength
|
|
238
|
+
end
|
|
239
|
+
end
|
|
@@ -12,7 +12,9 @@ module Labimotion
|
|
|
12
12
|
mount Labimotion::LabimotionHubAPI
|
|
13
13
|
mount Labimotion::StandardLayerAPI
|
|
14
14
|
mount Labimotion::VocabularyAPI
|
|
15
|
+
mount Labimotion::OntologyRootAPI
|
|
15
16
|
mount Labimotion::UserAPI
|
|
17
|
+
mount Labimotion::LabimotionAiAPI
|
|
16
18
|
mount Labimotion::UserKlassSettingsAPI
|
|
17
19
|
mount Labimotion::KlassShareAPI
|
|
18
20
|
mount Labimotion::MttAPI
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'labimotion/version'
|
|
4
|
+
|
|
5
|
+
module Labimotion
|
|
6
|
+
# Serves the stored subtree behind one "ontology-select" root, and lets a user
|
|
7
|
+
# refresh it.
|
|
8
|
+
#
|
|
9
|
+
# The client sends the `ontology_term` string its field already holds and the
|
|
10
|
+
# server derives the file path from it. Nothing about the naming scheme is
|
|
11
|
+
# public, so it can change later without touching the frontend or migrating
|
|
12
|
+
# anything — worst case the files are fetched again.
|
|
13
|
+
#
|
|
14
|
+
# One root per request. A field may pin several, and the picker already walks
|
|
15
|
+
# them one at a time.
|
|
16
|
+
class OntologyRootAPI < Grape::API
|
|
17
|
+
helpers Labimotion::GenericHelpers
|
|
18
|
+
|
|
19
|
+
resource :ontology_roots do
|
|
20
|
+
helpers do
|
|
21
|
+
# The one root a request is about, or a 400 naming what was unusable.
|
|
22
|
+
def ontology_root!(term)
|
|
23
|
+
root = Labimotion::OntologyTerms.parse(term).first
|
|
24
|
+
error!({ status: 'error', message: 'not a usable ontology term' }, 400) if root.nil?
|
|
25
|
+
|
|
26
|
+
root
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def refresher
|
|
30
|
+
current_user.respond_to?(:name_abbreviation) ? current_user.name_abbreviation : nil
|
|
31
|
+
rescue StandardError
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
desc "One root's subtree, as the picker and the auto-fill both read it"
|
|
37
|
+
params do
|
|
38
|
+
requires :term, type: String, desc: "The field's ontology_term value"
|
|
39
|
+
end
|
|
40
|
+
get do
|
|
41
|
+
root = ontology_root!(params[:term])
|
|
42
|
+
doc = Labimotion::OntologyStore.document(root, synced_by: refresher)
|
|
43
|
+
if doc.nil?
|
|
44
|
+
# The store could not be filled and the service could not be reached.
|
|
45
|
+
# The picker falls back to what it can, rather than showing an empty
|
|
46
|
+
# vocabulary as though the ontology genuinely had no terms.
|
|
47
|
+
{ status: 'error', message: 'this ontology could not be reached' }
|
|
48
|
+
else
|
|
49
|
+
{ status: 'success', root: doc }
|
|
50
|
+
end
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
Labimotion.log_exception(e, current_user)
|
|
53
|
+
{ status: 'error', message: e.message }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
desc 'Re-fetch this root from the ontology service and store what comes back'
|
|
57
|
+
params do
|
|
58
|
+
requires :term, type: String, desc: "The field's ontology_term value"
|
|
59
|
+
end
|
|
60
|
+
post :refresh do
|
|
61
|
+
root = ontology_root!(params[:term])
|
|
62
|
+
{ status: 'success', root: Labimotion::OntologyStore.refresh!(root, synced_by: refresher) }
|
|
63
|
+
rescue Labimotion::OntologyStore::Error => e
|
|
64
|
+
# Expected refusals — already refreshed a moment ago, someone else is
|
|
65
|
+
# refreshing, the result came back empty or collapsed. The person who
|
|
66
|
+
# pressed the button gets the reason; what is stored is untouched.
|
|
67
|
+
{ status: 'error', message: e.message }
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
Labimotion.log_exception(e, current_user)
|
|
70
|
+
{ status: 'error', message: e.message }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -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
|
data/lib/labimotion/conf.rb
CHANGED
|
@@ -3,4 +3,9 @@
|
|
|
3
3
|
## Labimotion Configuration
|
|
4
4
|
module Labimotion
|
|
5
5
|
KLASSES_JSON = Rails.root.join('public', 'klasses.json').to_s.freeze # Rails.root.join('app/packs/klasses.json').to_s.freeze
|
|
6
|
+
|
|
7
|
+
# One JSON file per ontology root, holding the subtree an "ontology-select"
|
|
8
|
+
# field may offer. Sits under the host's public/ontologies, which the ELN
|
|
9
|
+
# already gitignores and already lets this gem write to (see KLASSES_JSON).
|
|
10
|
+
ONTOLOGY_ROOTS_DIR = Rails.public_path.join('ontologies', 'roots').to_s.freeze
|
|
6
11
|
end
|
data/lib/labimotion/constants.rb
CHANGED
|
@@ -51,6 +51,17 @@ module Labimotion
|
|
|
51
51
|
LINKED_EL_ATTRS = 'linked_el_attrs'
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# The independent sub-settings held side by side in a KLASS's own `settings`
|
|
55
|
+
# jsonb (element_klasses / segment_klasses / dataset_klasses). A save
|
|
56
|
+
# shallow-merges, so writing one never disturbs the others.
|
|
57
|
+
module KlassSetting
|
|
58
|
+
LAYER_FUNCTIONS = 'layer_functions'
|
|
59
|
+
COVER_IMAGE = 'cover_image'
|
|
60
|
+
# Template-specific guidance for the AI data auto-fill, written by the
|
|
61
|
+
# designer and read by Labimotion::AiTemplate.fill.
|
|
62
|
+
AI_FILL_PROMPT = 'ai_fill_prompt'
|
|
63
|
+
end
|
|
64
|
+
|
|
54
65
|
# The designer-right families as keyed in `users.generic_admin` (host-owned jsonb) and
|
|
55
66
|
# read by authenticate_admin!. FAMILY_OF answers which family gates a klass type.
|
|
56
67
|
module Family
|
|
@@ -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: '',
|