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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3e6b4cffa98971ea0ef93e54a537710c7fdc587f2433f038a54f42be1ac63f21
4
- data.tar.gz: b57ba99244c3b9ec975fef1acb2c1b588b8d9bee5a5d6c356d336bc247c08738
3
+ metadata.gz: baacaf6374ee395ea0178ebb5d0ecf6d3fef5a0f37bb17852e4742ac6442ccee
4
+ data.tar.gz: 01ab63f69e28a3d990480c9dd6eb747f26bc7cc727c57ddcb94034a8e987937e
5
5
  SHA512:
6
- metadata.gz: c727c0b2409e463b0e6130986ebdcc528cf6473696b2124889061557f365ec163cc1393d08662b258801ddadafd16f7134e82786d759121b915e47b499814b55
7
- data.tar.gz: 84ac6b69a8326012b61eae9aefef2f248c85ac22fa45cf266cf03114378a0cde2d1839f0798d3d460d35223000283db2a7ee4dcb3bdcfa4e4faac414ad01460b
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
@@ -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
@@ -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: '',