labimotion 2.4.0.rc6 → 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: fa32d7e24804c4f270efdbce75bccf847ae3d30ae1f6b265c17b9c196e9c12a6
4
- data.tar.gz: b6901471f70ac6874bf68a517ea9d994a3e4054d089a2bc66e042de38e97f4fd
3
+ metadata.gz: baacaf6374ee395ea0178ebb5d0ecf6d3fef5a0f37bb17852e4742ac6442ccee
4
+ data.tar.gz: 01ab63f69e28a3d990480c9dd6eb747f26bc7cc727c57ddcb94034a8e987937e
5
5
  SHA512:
6
- metadata.gz: b6612883678081acfa046ea9956424f06b512f5ea941cac2dd219e60809f0ed308026d9e0e8c9a112e067b8718f4a2b55939211b0843100d6329a9502341ddee
7
- data.tar.gz: c52a79f87e8dffb30d340be92e107e6518c96e3c28d46a6857e6355ff3e30ec399a989cce94553188b4baff83ac548d34d80083b185a523be8c31823bdc7e97c
6
+ metadata.gz: 5463d7bffb59f2ef5ef5e664a8ac97fb28557965942a0b182ca2834ec33cc0d81a1ef275799f9b0b7cadb93c1851eff0dbf429c115c863d336c92e91d68395fb
7
+ data.tar.gz: b83156927689da21c02e5a632d526fd1eafb9cd93695ef407fc88cf6b7d65a49593519cb36b2ed9c93afffcb935bbfe3ad274950e549839f6ca40a605a4ec82f
@@ -232,6 +232,26 @@ module Labimotion
232
232
  # blocks below, otherwise POST /ai_fill_data falls through to the element
233
233
  # instance route and Element.find(nil) is raised.
234
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
+
235
255
  desc "Auto-fill a generic element's data values from a document using AI"
236
256
  params do
237
257
  use :ai_fill_element_data_params
@@ -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
@@ -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
@@ -136,8 +136,9 @@ module Labimotion
136
136
  text = ai_fill_source_text(element, params)
137
137
  overrides = ai_user_overrides(current_user)
138
138
  ai = Labimotion::AiTemplate.fill(
139
- properties: element.properties,
139
+ properties: ai_fill_properties(element),
140
140
  context_text: text,
141
+ instructions: ai_fill_instructions(element),
141
142
  **overrides
142
143
  )
143
144
  { status: 'success', values: ai['values'], summary: ai['summary'] }
@@ -146,6 +147,43 @@ module Labimotion
146
147
  { status: 'error', message: e.message }
147
148
  end
148
149
 
150
+ # The element's properties, with the select_options its own fields point at.
151
+ #
152
+ # An element instance never stores select_options: create and update both
153
+ # delete the key (see create_element/update_element below), because the
154
+ # options belong to the template, not to the instance. The fill prompt lists
155
+ # a select's allowed options so the model can pick one, and reads them out
156
+ # of the properties it is handed — so passing element.properties straight
157
+ # through offered the model an EMPTY list for every select and select-multi
158
+ # field. Nothing to choose from, so those fields came back unfilled, which
159
+ # reads as the AI having ignored them.
160
+ #
161
+ # properties_release, not the klass's current properties_template: it is the
162
+ # release this element was actually built against, so its option keys are the
163
+ # ones the element's fields reference and the ones the form will accept back.
164
+ # Same source the UI and the exporter read for this element.
165
+ def ai_fill_properties(element)
166
+ props = element.properties
167
+ return props unless props.is_a?(Hash)
168
+ return props if props['select_options'].present?
169
+
170
+ release = element.properties_release
171
+ options = release.is_a?(Hash) ? release['select_options'] : nil
172
+ return props if options.blank?
173
+
174
+ props.merge('select_options' => options)
175
+ end
176
+
177
+ # The template's own auto-fill guidance, from Template settings in the designer.
178
+ # Read from the KLASS rather than the element's copied properties, so editing it
179
+ # takes effect on elements that already exist instead of only on new ones.
180
+ def ai_fill_instructions(element)
181
+ settings = element.element_klass&.settings
182
+ return nil unless settings.is_a?(Hash)
183
+
184
+ settings[Labimotion::Constants::KlassSetting::AI_FILL_PROMPT].presence
185
+ end
186
+
149
187
  # Resolve the requested source into plain text, enforcing ownership first.
150
188
  def ai_fill_source_text(element, params)
151
189
  case params[:source]
@@ -155,6 +193,8 @@ module Labimotion
155
193
  ai_fill_analysis_text(element, params[:container_id])
156
194
  when 'upload'
157
195
  ai_fill_upload_text(params[:files])
196
+ when 'text'
197
+ ai_fill_pasted_text(params[:text])
158
198
  else
159
199
  error!('400 Bad Request', 400)
160
200
  end
@@ -205,6 +245,16 @@ module Labimotion
205
245
  parts.join("\n\n")
206
246
  end
207
247
 
248
+ # Text pasted straight into the dialog. Capped like an extracted file so a
249
+ # very long paste cannot blow the model's context or the request budget —
250
+ # the same MAX_FILE_CHARS the file path is held to.
251
+ def ai_fill_pasted_text(text)
252
+ body = text.to_s.strip
253
+ error!('400 Bad Request', 400) if body.empty?
254
+
255
+ body[0, Labimotion::AiTemplate::MAX_FILE_CHARS].to_s
256
+ end
257
+
208
258
  # Ids of attachments this element may expose to the AI fill: its own
209
259
  # attachments plus the attachments of its analysis dataset containers.
210
260
  def ai_fill_element_attachment_ids(element)
@@ -66,9 +66,10 @@ module Labimotion
66
66
  # `files` mirrors the create_ai_*_klass_params file block (source == 'upload').
67
67
  params :ai_fill_element_data_params do
68
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'
69
+ requires :source, type: String, values: %w[attachment analysis upload text], desc: 'Value source'
70
70
  optional :attachment_id, type: Integer, desc: 'Attachment id (required when source == attachment)'
71
71
  optional :container_id, type: Integer, desc: 'Analysis container id (required when source == analysis)'
72
+ optional :text, type: String, desc: 'Pasted plain text (source == text)'
72
73
  optional :files, type: Array, desc: 'Uploaded files (source == upload)' do
73
74
  optional :filename, type: String, desc: 'File name'
74
75
  optional :content, type: String, desc: 'Client-read plaintext (legacy, truncated)'