labimotion 2.4.0.rc8 → 2.4.0.rc9

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: fb80f2785578e7024da49b80d642b5d79360b576be7cd3035588d158e433ce9a
4
- data.tar.gz: d34d16a3ea0499082e3573e8486acfc0039729ede44defaf191079ca8bcb53ff
3
+ metadata.gz: c6b27ce7b185d248cf78a19fbeb00cf5c60edeed1e090d1fa73c2de8d4465579
4
+ data.tar.gz: dd64b3fd5e1fbb717135444ed666df30e2ae542e6e65fb2a3616b210ab9d5832
5
5
  SHA512:
6
- metadata.gz: fbd4bc4b60468e2ffabfd9da96d9425e3aa718a305747c794f35bd63d27ae3e0d630ee90d68b37a5ad353e307b692c41364ff905947b9b623f9d75a5d9dcedc9
7
- data.tar.gz: 5062fc57240fb3319a7a2a272fccd20eee6d379910c7b14c7ff9a0ca975d1920f0dc019a812873fa173796d8ad3e26bced01610f48d296b7e6d3d2d8787dff8a
6
+ metadata.gz: 3cbf525c1197064ab1b25a11d77fd94249e064d6f705c1ae5e9306bc497e7080c5c2c3266fd8bec52c2ee502862e8b82f6324d3e3aca20d2a6565dbcaa67f811
7
+ data.tar.gz: 5c96c935475319618a2c9eb66d5cd102a0971b6f7628a7bd97bcaa5f5cd8686933c6c3cef110d24be30250f01493569ae966f3eb07ecde06ed3e04e710668b64
data/CHANGELOG.md CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  ## [Unreleased]
4
4
  * Features and enhancements:
5
+ * The AI whitelist reading moves into the gem as `Labimotion::MatriceLabimotion`, a concern the host mixes into whichever model holds its UI feature configs (`Matrice` in the Chemotion ELN). Who is allowed stays the host's decision and the host's data — an admin edits `ai_uids` / `fill_uids` on the UI Features page — but what the answer means is LabIMotion's: which generic types have an AI integration (`AI_FEATURES`), that auto-fill is a sub-gate on `ai_uids` rather than a list of its own, and that every step is closed by default, so a missing feature row, a missing key, an empty list or a malformed value all resolve to nobody. Supplies `.ai_uids`, `.fill_uids`, `.ai_enabled_for?`, `.ai_features_for` and `.ai_enabled_for_any?`; the uid reader behind them stays private to the including model. A host needs two things for it to work: the including model answers `find_by(name:)` with a record exposing a `configs` hash, and its user answers `matrix_check_by_name(name)` with whether that feature is visible to them at all. This also settles where `Labimotion::LabimotionAiAPI`'s own `Matrice.ai_enabled_for_any?` gate comes from — the method is now defined by this gem rather than borrowed from whatever the host happened to implement. It also absorbs the auto-fill gate the host used to own as a `LabimotionAiAccess` service: `.fill_enabled_for?(user)` is `ai_uids` AND `fill_uids` on `FILL_FEATURE` (genericElement, the only feature carrying such a list — the designer's "New (AI)" buttons on the other two types are admin-only and need no second gate), so the gem's `ai_fill_data` route and the host's own `aiFill` flag answer through one method and cannot disagree about who may auto-fill. That drops the gem from two host constants to one: it no longer reaches for a `LabimotionAiAccess` a host had to implement, only for the model carrying this concern. Host ELN change is the `include`, the deletion of the moved methods and of that service; **the host will not boot without a gem carrying this concern**, since the model that includes it is loaded app-wide.
6
+ * Per-user settings now live in a gem-owned `user_settings` table instead of the host's profile jsonb, starting with the AI settings that were the only occupant. One row per `(user_id, key)`, each with its own `settings` jsonb and a unique index on the pair — `Labimotion::UserSetting`, keys enumerated in `Constants::UserSettingKey` (`ai` today, `my_layers` next). Split by row rather than one blob per user on purpose: the keys have nothing to do with each other and differ wildly in size, so a shared row would mean every save rewrites the lot and two features saving at once would clobber each other. `Labimotion::ProfileAiSettings` — the concern the host mixed into `Profile` — is **removed** and replaced by `Labimotion::UserAiSettings`, a wrapper over the `ai` row with the same field semantics it always had (nil leaves a field unchanged, a blank string clears it, the api_key is stored encrypted and only ever reported as set-or-not) and an injectable store so the shape can be exercised without a database. Encryption stays the host's `Encryptor`, unchanged, so stored ciphertext keeps decrypting. `Constants::UserSetting` is renamed `Constants::UserKlassSetting` — its members are the sub-keys of the per-klass table and the old name would now read as the new one's. Requires host ELN twin migrations `create_user_settings` and `move_labimotion_ai_settings_to_user_settings`; the latter copies each profile's `labimotion_ai` object verbatim (ciphertext included — re-encrypting would be the only way to get it wrong) and then deletes the profile key, unlike the `ui_preference` consolidation which left an inert value behind: this one would otherwise leave a second copy of a credential in a column several endpoints serialize. It is reversible, so rolling the gem back does not strand a key in a table nothing reads. Deploy order is migrate → **restart**.
7
+ * New template-sharing role `Maintainer` ([chem-generic-ui#1082](https://github.com/cllde8/chem-generic-ui/issues/1082)): everything an editor may do plus releasing **minor** versions — major releases, activation, deletion and share management stay owner-only. `KlassShare::LEVELS` gains `maintainer: 25` (additive, strictly between editor and owner, nobody renumbered — the one-owner partial index on level 30 is untouched, so no migration); `ShareResolver::REQUIRED_LEVEL` gains `release_minor: 25` while `release` keeps meaning the major at 30; `update_template` now asks `:write` for a draft, `:release_minor` for a minor and `:release` for a major — an unrecognised release value fails toward the stricter gate. List payloads gain `can_release_minor` under the same `share_context` guard as the other capability booleans, the grant and level-change routes accept level 25, and the `release_minor` refusal names maintainer access instead of claiming owner-only. Ships with the matching chem-generic-ui change (Minor button gated on the new capability; an absent field on an older gem falls back to `can_release`).
5
8
  * `Link Element` is now available as a column type inside `Table` fields of generic elements and segments ([chem-generic-ui#1055](https://github.com/LabIMotion/chem-generic-ui/issues/1055), labimotion#261). A `drag_element` cell stores the same payload the layer-level field stores (`el_id`, `el_klass`, `el_label`, `el_name`, `el_tip`, `icon_name`) and deliberately carries no `is_new`/`cr_opt`: a drop links a record that already exists and must never create one on save, unlike the sample column it sits next to. The stored label, name, tooltip and icon are refreshed on every read by the new `Labimotion::Serializer.set_table_element`, called from `PropertiesEntity` and `ElnElementEntity` as well, so all three property loaders agree rather than drifting into three copies. `SampleAssociation.build_table_element` writes the `Labimotion::ElementsElement` join rows and feeds the same `els` list that the stale-link cleanup compares against — feeding it is load-bearing, since the cleanup would otherwise `destroy_all` the rows it had just created. Both paths skip a cell whose `el_klass` is a `Labimotion::LinkedElement::MODELS` key: such a column accepts reactions, wellplates, screens and the rest as readily as generic elements, and for those `el_id` is not a `Labimotion::Element` id, so resolving it would relabel or link a wholly unrelated element — the same guard the layer-level field already applies. Carried through every export path: XLSX (`exporter_helpers`, expandable like the sample and molecule columns, short label as the cell text and `Name` as an optional sub-column), DOCX (`export_element`), and the ZIP round-trip (`export_utils` remaps element ids to uuids, `import_utils` re-links them through the new `proc_table_element`). A linked element has no structure image, so the cell shows the klass icon instead of an SVG.
6
9
  * Element cover images, write path ([chem-generic-ui#1050](https://github.com/LabIMotion/chem-generic-ui/issues/1050), labimotion#259): the element create and update saves now validate the `cover_images` key of the optional `metadata` param — an array of at most 3 `{ source: attachment|analysis, id: <integer> }` references, mirroring the chem-generic-ui picker's cap and vocabulary, normalized to exactly those two entry keys — and refuse a malformed selection with a plain 422 before any container/sample-association write runs. All cover-image helpers live in the dedicated `Labimotion::CoverImageHelpers` module, with the contract cap and source vocabulary as `Constants::CoverImage::MAX`/`SOURCES`. The rest of the metadata document keeps its existing contract: a present param replaces the stored hash wholesale, because its other writers (layer groups/restrictions from the details page, host regComps through the registry seam) remove a key by omitting it, while an absent param leaves stored metadata untouched — an old client that does not send `metadata` can never wipe a saved cover selection. References only, no FK: a deleted attachment/analysis leaves a dangling reference the UI skips at render time. No migration (`elements.metadata` and `elements_revisions.metadata` already exist), no entity change (`metadata` was already exposed), no klass change (the per-klass `cover_image` toggle rides the existing `update_settings` shallow merge); revision snapshots already carry `metadata` alongside `properties`, and restoring a revision round-trips the cover selection through this validated save.
7
10
  * Element cover images, original serving ([chem-generic-ui#1050](https://github.com/LabIMotion/chem-generic-ui/issues/1050), labimotion#259): new `GET /generic_elements/cover_image?element_id=&source=&id=` answers the original file behind a cover-image reference, so the element page's display area can show originals while thumbnails stay in the picker. Gated by the `genericElement` matrix check plus `ElementPolicy` `read?` **and** `read_dataset?` (analysis previews are dataset attachments, and the ELN's own attachment reads demand both); the reference resolves through the element's own associations only, so a foreign attachment or container id answers 404, never a file. An `analysis` reference resolves the container preview server-side for the first time, mirroring the client's `getContainerImageData`: the container's `preferred_thumbnail` while it still exists, else the newest "combined" image, else the newest thumbnail-bearing dataset attachment. The body is the original bytes under the file's own content type — or the PNG thumbnail when a browser could not render the original (TIFF, raw-data files behind an analysis preview). An optional `variant=thumbnail` always answers the PNG thumbnail through the same gate and resolution, which is what the picker's rows show — for an svg the stored thumbnail (black: ImageMagick reads `transparent` as black) is replaced by a fresh white-background rasterization of the original, through the same shared converter as the docx export — with it, the whole cover-image feature runs on gem-owned API surface and the host wires no fetcher at all. The picker's Save persists directly through the new `POST /generic_elements/cover_images` (`element_id`, `cover_images`; gated by `ElementPolicy` `update?` + the matrix check): the same validation as the element-save path, then a **targeted merge** of exactly the `cover_images` key into the stored metadata — deliberately narrower than the element save's wholesale-replace contract, so this writer can neither wipe nor resurrect `groups`/host keys. Live like klass settings: the write is immediate and unversioned until the next element save snapshots metadata into the revisions. The docx export (`ExportElement`) now renders the cover selection too: the stored references resolve through the same helpers as the `cover_image` endpoint, each image is embedded above the layers (originals for the rasters Word renders reliably — png/jpeg/gif/bmp; other renderable originals such as svg are rasterized onto white via MiniMagick, with the CSS keyword `transparent` normalized to `none` first because ImageMagick’s own SVG renderer reads it as black — the PNG thumbnail only as last resort), captioned "ref: …" and scaled to the page via MiniMagick-read dimensions (best effort — without them the template placeholder geometry is kept), Requires the matching `Labimotion_lines.docx` template change in the host ELN (a `has_cover`-guarded `cover_images:each` block before the layers loop); an old template simply ignores the new context keys.
@@ -233,19 +233,20 @@ module Labimotion
233
233
  # instance route and Element.find(nil) is raised.
234
234
  namespace :ai_fill_data do
235
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.
236
+ # create/refine routes are admin-only — so it carries its own whitelist:
237
+ # the genericElement AI list narrowed by :fill_uids. The rule is ours
238
+ # (Labimotion::MatriceLabimotion); the lists are the host's, on the
239
+ # model it mixes that concern into. Reached the same way this API already
240
+ # reaches ElementPolicy, and CLOSED when the host mounts no such model,
241
+ # matching how every other gate in this feature defaults.
241
242
  # NameError, not defined?: under Zeitwerk `defined?` is nil until the
242
243
  # constant has actually been referenced, so testing it would refuse the
243
244
  # 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
+ # a host that genuinely ships no such model raises, and that is a
245
246
  # refusal too.
246
247
  before do
247
248
  allowed = begin
248
- LabimotionAiAccess.fill_enabled_for?(current_user)
249
+ Matrice.fill_enabled_for?(current_user)
249
250
  rescue NameError
250
251
  false
251
252
  end
@@ -7,7 +7,7 @@ module Labimotion
7
7
  # Share management for generic templates (template-sharing.md §7). Its own API class, not
8
8
  # more namespaces in GenericKlassAPI, so the spec harness can load and mount it alone.
9
9
  #
10
- # Grant and level-change accept viewer/editor only: the owner level never moves through
10
+ # Grant and level-change accept viewer/editor/maintainer only: the owner level never moves through
11
11
  # these routes. It moves through transfer_ownership, an update of the single owner row, so
12
12
  # the one-owner-per-klass invariant holds at every instant and the handover is auditable.
13
13
  #
@@ -292,7 +292,7 @@ module Labimotion
292
292
  { mc: 'se00', msg: e.message }
293
293
  end
294
294
 
295
- desc 'grant viewer/editor access to users (upsert)'
295
+ desc 'grant viewer/editor/maintainer access to users (upsert)'
296
296
  params do
297
297
  requires :klass, type: String, desc: 'Klass', values: Labimotion::Constants::Klass::ALL
298
298
  requires :id, type: Integer, desc: 'Klass ID'
@@ -300,10 +300,11 @@ module Labimotion
300
300
  # rubocop:disable Style/RedundantArrayConstructor
301
301
  requires :user_ids, type: Array[Integer], desc: 'users to share with (Persons only)'
302
302
  # rubocop:enable Style/RedundantArrayConstructor
303
- # Literal 10/20 (viewer/editor), not KlassShare::LEVELS: params blocks run while
304
- # the routes are built, and referencing the ActiveRecord model there would force it
305
- # to load in any process that only mounts the API. Owner (30) is absent on purpose.
306
- requires :permission_level, type: Integer, desc: 'viewer or editor', values: [10, 20]
303
+ # Literal 10/20/25 (viewer/editor/maintainer), not KlassShare::LEVELS: params blocks
304
+ # run while the routes are built, and referencing the ActiveRecord model there would
305
+ # force it to load in any process that only mounts the API. Owner (30) is absent on
306
+ # purpose.
307
+ requires :permission_level, type: Integer, desc: 'viewer, editor or maintainer', values: [10, 20, 25]
307
308
  end
308
309
  post do
309
310
  klz = fetch_klass(params[:klass], params[:id])
@@ -331,7 +332,7 @@ module Labimotion
331
332
  # grant into an se00 (labimotion#255 — moved in from the host).
332
333
  Labimotion::ShareNotifier.grant(
333
334
  klass: klz, actor: current_user, user_ids: params[:user_ids].uniq,
334
- level: params[:permission_level] == 20 ? 'editor' : 'viewer'
335
+ level: { 10 => 'viewer', 20 => 'editor', 25 => 'maintainer' }.fetch(params[:permission_level])
335
336
  )
336
337
  { mc: 'ss00' }.merge(represent_shares(klz))
337
338
  rescue StandardError => e
@@ -339,13 +340,14 @@ module Labimotion
339
340
  { mc: 'se00', msg: e.message }
340
341
  end
341
342
 
342
- desc 'change the level of one share (viewer/editor only)'
343
+ desc 'change the level of one share (viewer/editor/maintainer only)'
343
344
  params do
344
345
  requires :share_id, type: Integer, desc: 'Share ID'
345
- # Literal 10/20 (viewer/editor), not KlassShare::LEVELS: params blocks run while
346
- # the routes are built, and referencing the ActiveRecord model there would force it
347
- # to load in any process that only mounts the API. Owner (30) is absent on purpose.
348
- requires :permission_level, type: Integer, desc: 'viewer or editor', values: [10, 20]
346
+ # Literal 10/20/25 (viewer/editor/maintainer), not KlassShare::LEVELS: params blocks
347
+ # run while the routes are built, and referencing the ActiveRecord model there would
348
+ # force it to load in any process that only mounts the API. Owner (30) is absent on
349
+ # purpose.
350
+ requires :permission_level, type: Integer, desc: 'viewer, editor or maintainer', values: [10, 20, 25]
349
351
  end
350
352
  put ':share_id' do
351
353
  row = find_share!(params[:share_id])
@@ -6,10 +6,9 @@ module Labimotion
6
6
  # Per-user LabIMotion AI settings: the model, an optional personal API key and
7
7
  # an optional provider endpoint of one's own.
8
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.
9
+ # They live in our own user_settings table under the 'ai' key
10
+ # (Labimotion::UserAiSettings). The routes stay under /profiles, where clients
11
+ # already reach them, but nothing is stored on the host's profile any more.
13
12
  #
14
13
  # The API key is stored encrypted and never returned; a read only reports
15
14
  # whether one is set.
@@ -18,6 +17,7 @@ module Labimotion
18
17
  # :models) lists none. A representative subset of the chat-capable KI-Toolbox
19
18
  # models (ki-toolbox.scc.kit.edu); the full list lives in the yml.
20
19
  DEFAULT_MODELS = %w[
20
+ kit.mistral-small-4-119b-a8b
21
21
  azure.gpt-4.1-mini
22
22
  azure.gpt-4.1
23
23
  azure.gpt-5
@@ -81,10 +81,16 @@ module Labimotion
81
81
  # with the user's saved model so their current selection is always valid.
82
82
  def labimotion_ai_models
83
83
  models = labimotion_ai_server_models
84
- append_ai_model(models, current_user.profile.labimotion_ai_model.to_s)
84
+ append_ai_model(models, ai_settings.model.to_s)
85
85
  models
86
86
  end
87
87
 
88
+ # This user's stored AI settings. Memoized per request: several of the
89
+ # routes below read it more than once.
90
+ def ai_settings
91
+ @ai_settings ||= Labimotion::UserAiSettings.for(current_user)
92
+ end
93
+
88
94
  # Model ids the SHARED server key permits — the server models plus the
89
95
  # ENV default. Unlike available_models this excludes the user's own saved
90
96
  # model, so it is safe to enforce a keyless user's selection against.
@@ -122,13 +128,13 @@ module Labimotion
122
128
 
123
129
  desc 'get the current user LabIMotion AI settings (never returns the key)'
124
130
  get do
125
- profile = current_user.profile
131
+ settings = ai_settings
126
132
  configured = Rails.configuration.respond_to?(:labimotion_ai) ? Rails.configuration.labimotion_ai : nil
127
133
  {
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,
134
+ model: settings.model,
135
+ api_key_set: settings.api_key?,
136
+ base_url: settings.base_url,
137
+ api_path: settings.api_path,
132
138
  available_models: labimotion_ai_models,
133
139
  default_model: (configured && configured[:model]).presence,
134
140
  default_base_url: (configured && configured[:base_url]).presence,
@@ -146,7 +152,7 @@ module Labimotion
146
152
  optional :clear_endpoint, type: Boolean, default: false, desc: 'Remove the stored provider endpoint'
147
153
  end
148
154
  put do
149
- profile = current_user.profile
155
+ settings = ai_settings
150
156
  api_key_arg =
151
157
  if params[:clear_api_key]
152
158
  '' # blank -> clear
@@ -162,7 +168,7 @@ module Labimotion
162
168
  elsif params[:api_key].present?
163
169
  true
164
170
  else
165
- profile.labimotion_ai_api_key?
171
+ settings.api_key?
166
172
  end
167
173
  # A keyless user's AI calls run on the shared server key, so the chosen
168
174
  # model must be one the admin approved. A user on their own key may pick
@@ -184,7 +190,7 @@ module Labimotion
184
190
  api_path_arg = params[:clear_endpoint] ? '' : params[:api_path]
185
191
  validate_ai_endpoint!(base_url_arg, will_have_key)
186
192
 
187
- summary = profile.update_labimotion_ai(
193
+ summary = settings.update(
188
194
  model: params[:model].presence, api_key: api_key_arg,
189
195
  base_url: base_url_arg, api_path: api_path_arg
190
196
  )
@@ -201,14 +207,14 @@ module Labimotion
201
207
 
202
208
  desc 'fetch the live model list from the AI provider with the user personal key'
203
209
  post 'models' do
204
- profile = current_user.profile
210
+ settings = ai_settings
205
211
  # Deliberately the PERSONAL key only: the shared server key must not be
206
212
  # sent to a user-supplied provider URL, and a keyless user's model choice
207
213
  # is restricted to labimotion_ai_admin_model_ids anyway.
208
214
  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
215
+ api_key: settings.api_key,
216
+ base_url: settings.base_url,
217
+ api_path: settings.api_path
212
218
  ).call
213
219
  { status: true, models: result[:models], endpoint: result[:endpoint] }
214
220
  rescue StandardError => e
@@ -217,15 +223,15 @@ module Labimotion
217
223
 
218
224
  desc 'test the current user LabIMotion AI connection (nothing is saved)'
219
225
  post 'test' do
220
- profile = current_user.profile
226
+ settings = ai_settings
221
227
  # Same values the runtime override path reads, so the test exercises the
222
228
  # real config: base_url/api_path are honored by the gem only alongside a
223
229
  # personal key, and a custom endpoint is SSRF-validated there.
224
230
  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
231
+ model: settings.model,
232
+ api_key: settings.api_key,
233
+ base_url: settings.base_url,
234
+ api_path: settings.api_path
229
235
  )
230
236
  { status: true, ok: true, model: result['model'],
231
237
  endpoint: result['endpoint'], ms: result['ms'] }
@@ -20,12 +20,12 @@ module Labimotion
20
20
  # as "leave alone"; an empty linked_el_attrs array reads as "clear".
21
21
  def submitted_settings(settings)
22
22
  settings = (settings || {}).transform_keys(&:to_s)
23
- toolbar = settings[Labimotion::Constants::UserSetting::TOOLBAR]
24
- attrs = settings[Labimotion::Constants::UserSetting::LINKED_EL_ATTRS]
23
+ toolbar = settings[Labimotion::Constants::UserKlassSetting::TOOLBAR]
24
+ attrs = settings[Labimotion::Constants::UserKlassSetting::LINKED_EL_ATTRS]
25
25
  {}.tap do |result|
26
- result[Labimotion::Constants::UserSetting::TOOLBAR] = toolbar if toolbar.is_a?(Hash)
26
+ result[Labimotion::Constants::UserKlassSetting::TOOLBAR] = toolbar if toolbar.is_a?(Hash)
27
27
  if attrs.is_a?(Array)
28
- result[Labimotion::Constants::UserSetting::LINKED_EL_ATTRS] = normalize_linked_el_attrs(attrs)
28
+ result[Labimotion::Constants::UserKlassSetting::LINKED_EL_ATTRS] = normalize_linked_el_attrs(attrs)
29
29
  end
30
30
  end
31
31
  end
@@ -46,11 +46,28 @@ module Labimotion
46
46
 
47
47
  # The independent sub-settings held side by side in
48
48
  # user_klass_settings.settings. A save writes only the ones it carries.
49
- module UserSetting
49
+ #
50
+ # Named for the table: these are the sub-keys of a per-user, per-KLASS row.
51
+ # The per-user, klass-independent settings are a different table with its own
52
+ # key list — see UserSettingKey below.
53
+ module UserKlassSetting
50
54
  TOOLBAR = 'toolbar'
51
55
  LINKED_EL_ATTRS = 'linked_el_attrs'
52
56
  end
53
57
 
58
+ # The `key` column of user_settings: one row per user per key, each owning an
59
+ # independent `settings` jsonb. A feature claims a key here and nothing else
60
+ # in the table can disturb it, which is the point of splitting by row rather
61
+ # than sharing one blob — a large layer library and a stored credential have
62
+ # no reason to be rewritten by each other's saves.
63
+ module UserSettingKey
64
+ # Per-user AI settings: model, personal API key (encrypted), provider
65
+ # endpoint. Read and written through Labimotion::UserAiSettings.
66
+ AI = 'ai'
67
+ # A user's own layers — visible to them alone, not published to a template.
68
+ MY_LAYERS = 'my_layers'
69
+ end
70
+
54
71
  # The independent sub-settings held side by side in a KLASS's own `settings`
55
72
  # jsonb (element_klasses / segment_klasses / dataset_klasses). A save
56
73
  # shallow-merges, so writing one never disturbs the others.
@@ -26,7 +26,7 @@ module Labimotion
26
26
  # in the class body would chain-autoload KlassShare (an ActiveRecord model) just to load
27
27
  # this entity, which the gem's Rails-less spec harness cannot do. The blocks only run when
28
28
  # a share context was preloaded, so inside them the constants are safe.
29
- %i[write release deactivate destroy manage].each do |action|
29
+ %i[write release release_minor deactivate destroy manage].each do |action|
30
30
  expose "can_#{action}", if: :share_context do |object, options|
31
31
  context = options[:share_context]
32
32
  key = Labimotion::KlassShare.key_of(object)
@@ -31,8 +31,11 @@ module Labimotion
31
31
  # the helpers. ShareResolver stays the enforcement authority.
32
32
  ACTION_PHRASE = {
33
33
  read: 'view', write: 'edit', release: 'release',
34
+ release_minor: 'release a minor version of',
34
35
  deactivate: 'activate or deactivate', destroy: 'delete', manage: 'manage sharing for'
35
36
  }.freeze
37
+ # `release_minor` is deliberately NOT here: a maintainer holds it too, so its refusal
38
+ # must not claim "only the owner can" — it gets its own maintainer wording below.
36
39
  OWNER_ONLY_ACTIONS = %i[release deactivate destroy manage].freeze
37
40
 
38
41
  # What makes a klass unique among the *active* rows. These are model validations only —
@@ -92,6 +95,8 @@ module Labimotion
92
95
  def authorization_refusal_msg(name, action)
93
96
  requirement = if OWNER_ONLY_ACTIONS.include?(action)
94
97
  "Only the owner can #{ACTION_PHRASE[action]} this template."
98
+ elsif action == :release_minor
99
+ "You need maintainer access to #{ACTION_PHRASE[action]} this template."
95
100
  elsif action == :read
96
101
  "You need shared access to #{ACTION_PHRASE[action]} this template."
97
102
  else
@@ -109,20 +114,18 @@ module Labimotion
109
114
  raise e
110
115
  end
111
116
 
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
+ # Per-user AI overrides (model + API key) from the user's own settings row.
118
+ # Returns {} when nothing is stored, so AiTemplate falls back to the
119
+ # server-wide yml/ENV configuration. Shared by the dataset / element /
120
+ # segment AI helpers.
117
121
  def ai_user_overrides(current_user)
118
- profile = current_user.respond_to?(:profile) ? current_user.profile : nil
119
- return {} if profile.nil?
122
+ return {} if current_user.nil?
120
123
 
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
+ settings = Labimotion::UserAiSettings.for(current_user)
125
+ api_key = settings.api_key
126
+ model = api_key.blank? ? ai_shared_key_model(settings.model) : settings.model
124
127
 
125
- { model: model, api_key: api_key }.merge(ai_provider_overrides(profile, api_key)).compact
128
+ { model: model, api_key: api_key }.merge(ai_provider_overrides(settings, api_key)).compact
126
129
  rescue StandardError => e
127
130
  Labimotion.log_exception(e, current_user)
128
131
  {}
@@ -131,13 +134,10 @@ module Labimotion
131
134
  # Personal provider endpoint (base_url/api_path) — honored only ALONGSIDE a
132
135
  # personal key, so the shared server key is never sent to a user-supplied URL.
133
136
  # The base_url itself is SSRF-validated later, in AiTemplate.
134
- def ai_provider_overrides(profile, api_key)
137
+ def ai_provider_overrides(settings, api_key)
135
138
  return {} if api_key.blank?
136
139
 
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
- }
140
+ { base_url: settings.base_url, api_path: settings.api_path }
141
141
  end
142
142
 
143
143
  # On the SHARED server key (user has no personal key), keep the user's model
@@ -205,10 +205,18 @@ module Labimotion
205
205
 
206
206
  def update_template(params, current_user)
207
207
  klz = fetch_klass(params[:klass], params[:id])
208
- # The split that makes `editor` mean "drafting only": a draft save needs :write, while
209
- # anything else bumps the version and cuts a revision publishing, which is :release.
208
+ # The split that makes `editor` mean "drafting only" and `maintainer` mean "drafting
209
+ # plus minor releases": a draft save needs :write; a minor release bumps the version
210
+ # and cuts a revision, which a maintainer may do (:release_minor); a major release —
211
+ # and any release value nobody recognises — stays owner-only (:release), so an unknown
212
+ # value fails toward the stricter gate.
210
213
  # Authorization before the stale check: who may act comes before whether the copy is fresh.
211
- authorize_klass!(klz, params[:release] == 'draft' ? :write : :release)
214
+ release_action = case params[:release]
215
+ when 'draft' then :write
216
+ when 'minor' then :release_minor
217
+ else :release
218
+ end
219
+ authorize_klass!(klz, release_action)
212
220
  guard_stale_template!(klz, params[:properties_template])
213
221
  uuid = SecureRandom.uuid
214
222
  properties = params[:properties_template]
@@ -16,7 +16,7 @@ module Labimotion
16
16
  # :api_key / KI_TOOLBOX_API_KEY (required) bearer token (created in the UI)
17
17
  # :base_url / KI_TOOLBOX_BASE_URL default https://ki-toolbox.scc.kit.edu
18
18
  # :api_path / KI_TOOLBOX_API_PATH default /api/v1/chat/completions
19
- # :model / KI_TOOLBOX_MODEL default azure.gpt-4.1-mini
19
+ # :model / KI_TOOLBOX_MODEL default kit.mistral-small-4-119b-a8b
20
20
  # :max_tokens, :timeout, :system_prompt
21
21
  #
22
22
  # The LLM is asked to return a JSON document describing the template layers
@@ -26,7 +26,7 @@ module Labimotion
26
26
  class AiTemplate
27
27
  DEFAULT_BASE_URL = 'https://ki-toolbox.scc.kit.edu'
28
28
  DEFAULT_API_PATH = '/api/v1/chat/completions'
29
- DEFAULT_MODEL = 'azure.gpt-4.1-mini'
29
+ DEFAULT_MODEL = 'kit.mistral-small-4-119b-a8b'
30
30
  DEFAULT_MAX_TOKENS = 8000
31
31
  # A connection test only needs a valid round-trip, not a useful completion, so
32
32
  # cap the reply hard to keep the ping cheap and fast.
@@ -422,6 +422,12 @@ module Labimotion
422
422
  # POST an OpenAI-compatible chat-completions request for the given messages.
423
423
  # tokens defaults to the full max_tokens; the connection ping passes a small
424
424
  # cap so a test round-trip stays cheap.
425
+ #
426
+ # A timeout is named rather than left as Net::ReadTimeout. That exception
427
+ # carries no message a client can show, so it reached the dialog as "Unknown
428
+ # error" with the only evidence in log/labimotion.log — and it is the likeliest
429
+ # failure of the lot, because generating a whole template is a far longer
430
+ # request than the connection test that appears to succeed beside it.
425
431
  def post_chat(messages, tokens = max_tokens)
426
432
  HTTParty.post(
427
433
  "#{base_url}#{api_path}",
@@ -439,6 +445,11 @@ module Labimotion
439
445
  # is the classic SSRF bypass of the egress guard.
440
446
  follow_redirects: false
441
447
  )
448
+ # Timeout::Error alone: Net::OpenTimeout and Net::ReadTimeout both descend
449
+ # from it, so naming those too would only shadow them.
450
+ rescue Timeout::Error
451
+ raise "AI provider did not respond within #{request_timeout}s — choose a faster " \
452
+ 'model in your AI settings, or raise :timeout in config/labimotion_ai.yml.'
442
453
  end
443
454
 
444
455
  # OpenAI-compatible chat completions return the text at
@@ -1361,10 +1372,14 @@ module Labimotion
1361
1372
  some with a fixed "unit". Return an ARRAY of row objects keyed by those
1362
1373
  column NAMES, one object per row the document describes, e.g.
1363
1374
  [ {"Concentration": 0.5}, {"Concentration": 10} ]
1364
- A cell follows its column's type including the system-defined rule
1365
- above, so a column offering "units" takes {"value":..,"unit":".."} too.
1366
- Leave out a column you have no value for, and omit the field entirely if
1367
- the text describes no rows.
1375
+ A cell follows its column's type, by the SAME rules as a field of that
1376
+ type above: a column offering "units" takes {"value":..,"unit":".."},
1377
+ and a "select" column carries its own "options" and takes one of their
1378
+ "key" values. Fill a column for EVERY row it applies to, not just the
1379
+ rows where the document repeats it — when the text describes a set of
1380
+ rows that share a property, each row still states it. Leave out a
1381
+ column you have no value for, and omit the field entirely if the text
1382
+ describes no rows.
1368
1383
  - "text" / "textarea" -> a string.
1369
1384
  - If you find no values at all, return { "values": {}, "summary": "..." }.
1370
1385
 
@@ -1443,7 +1458,7 @@ module Labimotion
1443
1458
  when 'system-defined'
1444
1459
  entry.merge!(unit_info(field))
1445
1460
  when 'table'
1446
- columns = fill_table_columns(field)
1461
+ columns = fill_table_columns(field, select_options)
1447
1462
  return nil if columns.empty?
1448
1463
 
1449
1464
  entry['columns'] = columns
@@ -1479,11 +1494,14 @@ module Labimotion
1479
1494
  end
1480
1495
  end
1481
1496
 
1482
- # The columns of a "table" field, as the model can talk about them: name, type
1483
- # and the unit a measured column is fixed to. Columns that hold a dragged ELN
1484
- # record (a sample, a molecule) are dropped nothing in a document's text can
1485
- # resolve to one, so offering them only invites made-up rows.
1486
- def fill_table_columns(field)
1497
+ # The columns of a "table" field, as the model can talk about them: name, type,
1498
+ # the unit a measured column is fixed to and the options a choice column allows.
1499
+ # A select column without its options is unfillable in exactly the way a select
1500
+ # FIELD without them is: the model is asked for a value from a list it was never
1501
+ # shown, so it leaves the column empty. Columns that hold a dragged ELN record
1502
+ # (a sample, a molecule) are dropped — nothing in a document's text can resolve
1503
+ # to one, so offering them only invites made-up rows.
1504
+ def fill_table_columns(field, select_options)
1487
1505
  Array(field['sub_fields']).filter_map do |col|
1488
1506
  next unless col.is_a?(Hash)
1489
1507
 
@@ -1491,7 +1509,21 @@ module Labimotion
1491
1509
  type = col['type'].to_s
1492
1510
  next if name.empty? || type.start_with?('drag')
1493
1511
 
1494
- { 'name' => name, 'type' => type }.merge(type == 'system-defined' ? unit_info(col) : {})
1512
+ { 'name' => name, 'type' => type }.merge(fill_column_choices(col, type, select_options))
1513
+ end
1514
+ end
1515
+
1516
+ # What a column is allowed to hold, by type: the unit group of a measured
1517
+ # column, the option list of a choice column. Both are keyed off the column's
1518
+ # own `option_layers`, exactly as a top-level field's are.
1519
+ def fill_column_choices(col, type, select_options)
1520
+ case type
1521
+ when 'system-defined'
1522
+ unit_info(col)
1523
+ when 'select', 'select-multi'
1524
+ { 'options' => fill_option_pairs(select_options, col) }
1525
+ else
1526
+ {}
1495
1527
  end
1496
1528
  end
1497
1529
 
@@ -1710,7 +1742,7 @@ module Labimotion
1710
1742
  when 'datetime-range'
1711
1743
  coerce_duration(value)
1712
1744
  when 'table'
1713
- coerce_table_rows(field, value)
1745
+ coerce_table_rows(field, value, select_options)
1714
1746
  else
1715
1747
  coerce_fill_text(value)
1716
1748
  end
@@ -1738,12 +1770,12 @@ module Labimotion
1738
1770
  # to ids when it applies them (the same split as select-multi's sub_fields).
1739
1771
  # Unknown column names and uncoercible cells are dropped; a row left empty by
1740
1772
  # that goes too, so a table never gains a blank line.
1741
- def coerce_table_rows(field, value)
1773
+ def coerce_table_rows(field, value, select_options)
1742
1774
  return nil unless value.is_a?(Array)
1743
1775
 
1744
1776
  # rubocop:disable Rails/IndexBy -- Enumerable#index_by is ActiveSupport;
1745
1777
  # the gem's own suite boots without it (spec/support/core_extensions.rb).
1746
- columns = fill_table_columns(field).to_h { |c| [c['name'], c] }
1778
+ columns = fill_table_columns(field, select_options).to_h { |c| [c['name'], c] }
1747
1779
  # rubocop:enable Rails/IndexBy
1748
1780
  rows = value.first(MAX_TABLE_ROWS).filter_map { |row| coerce_table_row(columns, row) }
1749
1781
  rows.empty? ? nil : rows
@@ -1776,11 +1808,27 @@ module Labimotion
1776
1808
  coerce_measured_choices(column['units'], cell)
1777
1809
  when 'checkbox'
1778
1810
  fill_to_boolean(cell)
1811
+ when 'select', 'select-multi'
1812
+ coerce_column_option(column, cell)
1779
1813
  else
1780
1814
  coerce_fill_text(cell)
1781
1815
  end
1782
1816
  end
1783
1817
 
1818
+ # A choice cell as the option KEY the grid renders from. An answer given as
1819
+ # the human label is mapped back, otherwise a column whose key and label
1820
+ # differ ("reference" / "reference standard") would store a string the
1821
+ # dropdown cannot match and the cell would read as empty.
1822
+ def coerce_column_option(column, cell)
1823
+ pairs = Array(column['options'])
1824
+ if column['type'].to_s == 'select-multi'
1825
+ list = cell.is_a?(Array) ? cell : [cell]
1826
+ return list.filter_map { |v| resolve_option_key(pairs, v) }.uniq
1827
+ end
1828
+
1829
+ resolve_option_key(pairs, cell.is_a?(Array) ? cell.first : cell)
1830
+ end
1831
+
1784
1832
  # A measured value: a bare number (read in the field's own unit) or a
1785
1833
  # { 'value', 'unit' } pair naming one of the field's units.
1786
1834
  def coerce_measured(defn, value)
@@ -9,14 +9,18 @@ module Labimotion
9
9
  # a hundred-template list must cost a fixed number of queries, not a lookup per row
10
10
  # (template-sharing.md §7).
11
11
  class ShareResolver
12
- # The authorization ladder (§6). Six names even where thresholds coincide: the call site
12
+ # The authorization ladder (§6). Seven names even where thresholds coincide: the call site
13
13
  # documents intent, and the collapse stays visible in one constant instead of drifting
14
- # apart across the codebase. Values are KlassShare::LEVELS (10 viewer / 20 editor /
15
- # 30 owner) as literalsdereferencing the ActiveRecord model in this class body would
16
- # force it to load in any process that touches the resolver, and the numbers are already
17
- # frozen into the one-owner partial index anyway.
14
+ # apart across the codebase. `release_minor` (25, the maintainer's one extra right) is
15
+ # distinct from `release` (30, major still owner-only) for the same reason: the two
16
+ # release buttons ask two different questions, and collapsing them here is how an editor
17
+ # once ended up refused on a button the grid had offered. Values are KlassShare::LEVELS
18
+ # (10 viewer / 20 editor / 25 maintainer / 30 owner) as literals — dereferencing the
19
+ # ActiveRecord model in this class body would force it to load in any process that touches
20
+ # the resolver, and the numbers are already frozen into the one-owner partial index anyway.
18
21
  REQUIRED_LEVEL = {
19
- read: 10, write: 20, release: 30, deactivate: 30, destroy: 30, manage: 30
22
+ read: 10, write: 20, release_minor: 25, release: 30, deactivate: 30, destroy: 30,
23
+ manage: 30
20
24
  }.freeze
21
25
 
22
26
  # The requester's inbox predicate (§7), verbatim from the design, as one SQL fragment
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labimotion
4
+ ## Per-user LabIMotion AI settings: the model, an optional personal API key and
5
+ # an optional provider endpoint of one's own.
6
+ #
7
+ # These used to sit in the host's profile jsonb. They now live in our own
8
+ # user_settings table under the 'ai' key, because the settings are LabIMotion's
9
+ # and the host's profile is not a good place to keep growing them — see
10
+ # Labimotion::UserSetting.
11
+ #
12
+ # The API key is stored ENCRYPTED and never returned to a client: only whether
13
+ # one is set. Encryption stays the HOST's — key management is a deployment
14
+ # concern and a gem has no business inventing one. Chemotion supplies it as an
15
+ # `Encryptor` concern, pulled in below when present. A host that names its
16
+ # encryption differently defines `encrypt_value` / `decrypt_value` instead.
17
+ #
18
+ # settings = Labimotion::UserAiSettings.for(current_user)
19
+ # settings.model # => 'azure.gpt-4.1'
20
+ # settings.api_key? # => true (never the key itself, to a client)
21
+ # settings.update(model: 'azure.gpt-5')
22
+ #
23
+ # `store` is injectable so the shape can be exercised without a database.
24
+ class UserAiSettings
25
+ KEY = Labimotion::Constants::UserSettingKey::AI
26
+
27
+ # Reference, not `defined?`: under Zeitwerk the latter reads nil until a
28
+ # constant has been touched, while referencing it triggers the autoload.
29
+ # rubocop:disable Layout/EmptyLinesAfterModuleInclusion -- the blank line
30
+ # this wants collides with EmptyLinesAroundExceptionHandlingKeywords.
31
+ begin
32
+ include Encryptor
33
+ rescue NameError
34
+ nil
35
+ end
36
+ # rubocop:enable Layout/EmptyLinesAfterModuleInclusion
37
+
38
+ attr_reader :user_id
39
+
40
+ def initialize(user_id, store: Labimotion::UserSetting)
41
+ @user_id = user_id
42
+ @store = store
43
+ end
44
+
45
+ # From whatever the host calls a user. Accepts the record or a bare id.
46
+ def self.for(user, store: Labimotion::UserSetting)
47
+ new(user.respond_to?(:id) ? user.id : user, store: store)
48
+ end
49
+
50
+ def settings
51
+ @store.settings_for(@user_id, KEY)
52
+ end
53
+
54
+ def model
55
+ settings['model'].presence
56
+ end
57
+
58
+ # Personal provider endpoint (BYO-Provider). Honored only alongside a
59
+ # personal key, and SSRF-validated by Labimotion::AiEgressGuard before any
60
+ # request is made.
61
+ def base_url
62
+ settings['base_url'].presence
63
+ end
64
+
65
+ def api_path
66
+ settings['api_path'].presence
67
+ end
68
+
69
+ def api_key?
70
+ settings['api_key'].present?
71
+ end
72
+
73
+ # Decrypted personal API key, or nil when none is stored.
74
+ def api_key
75
+ enc = settings['api_key']
76
+ return nil if enc.blank?
77
+
78
+ decrypt_value(enc).presence
79
+ end
80
+
81
+ # What is safe to hand a client: everything but the key, which is reported
82
+ # only as set-or-not.
83
+ def summary
84
+ stored = settings
85
+ { model: stored['model'], api_key_set: stored['api_key'].present?,
86
+ base_url: stored['base_url'], api_path: stored['api_path'] }
87
+ end
88
+
89
+ # Persist the settings. For every field: nil leaves it unchanged, a blank
90
+ # string clears it, anything else is stored (the api_key encrypted).
91
+ # Returns the safe summary — never the key itself.
92
+ def update(model: nil, api_key: nil, base_url: nil, api_path: nil)
93
+ @store.update_settings(@user_id, KEY) do |ai|
94
+ set_or_clear(ai, 'model', model)
95
+ set_or_clear_api_key(ai, api_key)
96
+ set_or_clear(ai, 'base_url', base_url)
97
+ set_or_clear(ai, 'api_path', api_path)
98
+ ai
99
+ end
100
+ summary
101
+ end
102
+
103
+ private
104
+
105
+ # nil -> leave unchanged; blank string -> clear; else store the trimmed value.
106
+ def set_or_clear(stored, key, value)
107
+ return if value.nil?
108
+
109
+ trimmed = value.to_s.strip
110
+ if trimmed.blank?
111
+ stored.delete(key)
112
+ else
113
+ stored[key] = trimmed
114
+ end
115
+ end
116
+
117
+ # As set_or_clear, but the value is encrypted on the way in.
118
+ def set_or_clear_api_key(stored, value)
119
+ return if value.nil?
120
+
121
+ trimmed = value.to_s.strip
122
+ if trimmed.blank?
123
+ stored.delete('api_key')
124
+ else
125
+ stored['api_key'] = encrypt_value(trimmed)
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labimotion
4
+ ## The LabIMotion side of a host's UI-feature model — whatever this gem needs
5
+ # to read out of the per-feature JSON configs an admin edits.
6
+ #
7
+ # Today that is one thing: which users may reach the AI integration. Named for
8
+ # the seam rather than for that one job, so the next setting we need from a
9
+ # feature's configs lands beside it instead of in a second concern mixed into
10
+ # the same model.
11
+ #
12
+ # The division throughout: which users are allowed is the HOST's decision and
13
+ # the host's data — an admin edits the lists on the UI Features page and they
14
+ # live in the host's own table. What the answer MEANS is LabIMotion's: which
15
+ # generic types have an AI integration, that auto-fill is a sub-gate rather
16
+ # than a separate one, and that every step is closed by default. So the shape
17
+ # lives here and the storage stays there, the same division the rest of this
18
+ # gem keeps with its host.
19
+ #
20
+ # Chemotion holds the configs on its `Matrice` model, which includes this:
21
+ #
22
+ # class Matrice < ApplicationRecord
23
+ # include Labimotion::MatriceLabimotion
24
+ # end
25
+ #
26
+ # A host supplying it needs two things: the including model must answer
27
+ # `find_by(name:)` with a record exposing a `configs` hash, and its user must
28
+ # answer `matrix_check_by_name(name)` with whether that feature is on for them
29
+ # at all. Both already hold for the model this was extracted from.
30
+ #
31
+ # The configs a feature may carry:
32
+ #
33
+ # { "ai_uids": [109, 147], "fill_uids": [109] }
34
+ #
35
+ module MatriceLabimotion
36
+ extend ActiveSupport::Concern
37
+
38
+ # UI features whose JSON configs may carry an AI whitelist. Each one gates the
39
+ # LabIMotion AI integration of its own generic type (the designer's "New (AI)"
40
+ # / "Fine-tune with AI" buttons, and — for genericElement — the AI auto-fill in
41
+ # the element detail view).
42
+ AI_FEATURES = %w[genericElement segment genericDataset].freeze
43
+
44
+ # The feature carrying the auto-fill whitelist. Only this one has a
45
+ # `fill_uids` list: auto-fill is the AI action ordinary users reach, in the
46
+ # element detail view, while the designer's "New (AI)" buttons on the other
47
+ # two types are admin-only and need no second gate.
48
+ FILL_FEATURE = 'genericElement'
49
+
50
+ # rubocop:disable Metrics/BlockLength -- a concern's class_methods block is a
51
+ # namespace for the methods it contributes, not a unit of logic to keep short.
52
+ class_methods do
53
+ # User ids allowed to use the AI integration of a UI feature, read from that
54
+ # feature's JSON configs:
55
+ #
56
+ # { "ai_uids": [109] }
57
+ def ai_uids(name)
58
+ whitelist_uids(name, 'ai_uids')
59
+ end
60
+
61
+ # User ids allowed to run AI auto-fill, read from the same JSON configs:
62
+ #
63
+ # { "ai_uids": [109, 147], "fill_uids": [109] }
64
+ #
65
+ # A sub-gate on ai_uids, not a separate one — auto-fill is the AI action
66
+ # ordinary users reach, so it is narrowed to a subset of the group that sees
67
+ # the integration at all. Only genericElement consults it; the designer's
68
+ # "New (AI)" buttons are admin-only and have no equivalent.
69
+ def fill_uids(name)
70
+ whitelist_uids(name, 'fill_uids')
71
+ end
72
+
73
+ # True when `user` may use the AI integration of `name`. The whitelist is a
74
+ # sub-gate on top of normal feature visibility, so a whitelisted user still
75
+ # sees nothing when the feature itself is off for them.
76
+ def ai_enabled_for?(user, name)
77
+ return false if user.nil?
78
+
79
+ ai_uids(name).include?(user.id) && user.matrix_check_by_name(name)
80
+ end
81
+
82
+ # { 'genericElement' => true, 'segment' => false, ... } for the UI to gate on.
83
+ def ai_features_for(user)
84
+ AI_FEATURES.index_with { |name| ai_enabled_for?(user, name) }
85
+ end
86
+
87
+ # True when `user` is whitelisted for at least one generic type — the gate for
88
+ # shared surfaces such as the "AI" tab in My LabIMotion.
89
+ def ai_enabled_for_any?(user)
90
+ AI_FEATURES.any? { |name| ai_enabled_for?(user, name) }
91
+ end
92
+
93
+ # True when `user` may run AI auto-fill (the wand in the generic element
94
+ # toolbar, POST /generic_elements/ai_fill_data). Takes no feature name
95
+ # because only FILL_FEATURE has such a list.
96
+ #
97
+ # Both gates must pass: `ai_uids` decides who sees the AI integration at
98
+ # all, `fill_uids` narrows that group. It narrows rather than widens, so a
99
+ # user on fill_uids alone gets nothing.
100
+ #
101
+ # The endpoint is defined in this gem and the host reports the same answer
102
+ # to its client, both through this one method, so the button and the
103
+ # endpoint can never disagree about who may use it.
104
+ def fill_enabled_for?(user)
105
+ return false if user.nil?
106
+
107
+ ai_enabled_for?(user, FILL_FEATURE) && fill_uids(FILL_FEATURE).include?(user.id)
108
+ end
109
+
110
+ private
111
+
112
+ # Closed by default: a missing key, a non-array value or an empty list all mean
113
+ # nobody. Non-numeric entries are dropped rather than raising, so a typo in the
114
+ # admin JSON editor narrows the whitelist instead of breaking the page.
115
+ def whitelist_uids(name, key)
116
+ uids = (find_by(name: name)&.configs || {})[key]
117
+ return [] unless uids.is_a?(Array)
118
+
119
+ uids.filter_map { |uid| Integer(uid, exception: false) }
120
+ end
121
+ end
122
+ # rubocop:enable Metrics/BlockLength
123
+ end
124
+ end
@@ -31,9 +31,12 @@ module Labimotion
31
31
  # message in the main ELN, this acknowledges a share row in the Designer, and the two answer
32
32
  # different questions in different places.
33
33
 
34
- # Strictly nested ladder (requested ⊂ viewer ⊂ editor ⊂ owner), which is what makes `>=`
35
- # threshold checks valid. Spaced by ten so a future level is additive; never renumber
36
- # the one-owner-per-klass partial index freezes `permission_level = 30` into the schema.
34
+ # Strictly nested ladder (requested ⊂ viewer ⊂ editor ⊂ maintainer ⊂ owner), which is
35
+ # what makes `>=` threshold checks valid. Spaced by ten so a future level is additive —
36
+ # `maintainer` (25) is exactly that: everything an editor may do plus releasing minor
37
+ # versions, slotted between editor and owner without renumbering anybody. Never
38
+ # renumber — the one-owner-per-klass partial index freezes `permission_level = 30`
39
+ # into the schema.
37
40
  #
38
41
  # `requested` is the odd one: it grants nothing. It is how an access request is stored —
39
42
  # a row rather than a message, because the last hop of the request loop lands on the
@@ -42,7 +45,7 @@ module Labimotion
42
45
  # state changes on it, and the unique (klass_type, klass_id, shared_with_id) index
43
46
  # dedupes repeat asks for free. `0 >= 10` is false, so every threshold check refuses it
44
47
  # without a special case.
45
- LEVELS = { requested: 0, viewer: 10, editor: 20, owner: 30 }.freeze
48
+ LEVELS = { requested: 0, viewer: 10, editor: 20, maintainer: 25, owner: 30 }.freeze
46
49
 
47
50
  # Which account types may hold a share row. `Admin` is in the list because both seeding
48
51
  # paths have to reach the same verdict: the seed migration is raw SQL over `created_by`
@@ -18,8 +18,8 @@ module Labimotion
18
18
  # key itself as the identifier — 'sample', 'reaction', ...
19
19
  PERMIT_TARGET = Labimotion::Constants::Klass::PERMIT_TARGET
20
20
 
21
- TOOLBAR = Labimotion::Constants::UserSetting::TOOLBAR
22
- LINKED_EL_ATTRS = Labimotion::Constants::UserSetting::LINKED_EL_ATTRS
21
+ TOOLBAR = Labimotion::Constants::UserKlassSetting::TOOLBAR
22
+ LINKED_EL_ATTRS = Labimotion::Constants::UserKlassSetting::LINKED_EL_ATTRS
23
23
 
24
24
  self.table_name = :user_klass_settings
25
25
 
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labimotion
4
+ # Per-user, klass-independent settings — one row per (user, key), each with its
5
+ # own `settings` jsonb.
6
+ #
7
+ # The sibling table user_klass_settings holds what a user configures ABOUT a
8
+ # klass (toolbar, linked-element attributes); this one holds what belongs to
9
+ # the user themselves, whatever klass they are looking at: their AI credentials
10
+ # today, their own layers next.
11
+ #
12
+ # Split by row rather than one settings blob per user on purpose. The keys have
13
+ # nothing to do with each other and differ wildly in size — a stored credential
14
+ # is a few hundred bytes, a personal layer library can be a lot more — so
15
+ # sharing a row would mean every save rewrites the lot, and two features saving
16
+ # at once would clobber each other. A unique index on (user_id, key) keeps one
17
+ # row per feature per user; see Labimotion::Constants::UserSettingKey for the
18
+ # keys in use.
19
+ #
20
+ # Values are read and written through a per-key wrapper that owns the shape
21
+ # (Labimotion::UserAiSettings for AI), not by reaching into `settings` from
22
+ # callers — the same division the klass-settings API keeps.
23
+ class UserSetting < ApplicationRecord
24
+ self.table_name = :user_settings
25
+
26
+ # The stored hash for one key, or {} when the user has never saved any.
27
+ # Never nil, so callers can read straight through it.
28
+ def self.settings_for(user_id, key)
29
+ return {} if user_id.blank? || key.blank?
30
+
31
+ find_by(user_id: user_id, key: key)&.settings || {}
32
+ end
33
+
34
+ # Replace the stored hash for one key. Blank-out (an empty hash) deletes the
35
+ # row rather than leaving an empty one behind, so "has this user configured
36
+ # anything?" stays answerable by the row's existence.
37
+ def self.write_settings(user_id, key, settings)
38
+ raise ArgumentError, 'user_id is required' if user_id.blank?
39
+ raise ArgumentError, 'key is required' if key.blank?
40
+
41
+ row = find_or_initialize_by(user_id: user_id, key: key)
42
+ if settings.blank?
43
+ row.destroy! if row.persisted?
44
+ return {}
45
+ end
46
+
47
+ row.settings = settings
48
+ row.save!
49
+ row.settings
50
+ end
51
+
52
+ # Read-modify-write of one key's hash. The block is handed a mutable copy and
53
+ # whatever it returns is stored, so a caller changing one field cannot drop
54
+ # the others by accident.
55
+ def self.update_settings(user_id, key)
56
+ current = settings_for(user_id, key).dup
57
+ write_settings(user_id, key, yield(current))
58
+ end
59
+ end
60
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  ## Labimotion Version
4
4
  module Labimotion
5
- VERSION = '2.4.0.rc8'
5
+ VERSION = '2.4.0.rc9'
6
6
  end
data/lib/labimotion.rb CHANGED
@@ -90,6 +90,7 @@ module Labimotion
90
90
  autoload :AiTemplate, 'labimotion/libs/ai_template'
91
91
  autoload :AiEgressGuard, 'labimotion/libs/ai_egress_guard'
92
92
  autoload :AiModels, 'labimotion/libs/ai_models'
93
+ autoload :UserAiSettings, 'labimotion/libs/user_ai_settings'
93
94
  autoload :OntologyTerms, 'labimotion/libs/ontology_terms'
94
95
  autoload :OntologyStore, 'labimotion/libs/ontology_store'
95
96
  autoload :FileExtractor, 'labimotion/libs/file_extractor'
@@ -152,6 +153,7 @@ module Labimotion
152
153
  autoload :StdLayer, 'labimotion/models/std_layer'
153
154
  autoload :StdLayersRevision, 'labimotion/models/std_layers_revision'
154
155
  autoload :UserKlassSetting, 'labimotion/models/user_klass_setting'
156
+ autoload :UserSetting, 'labimotion/models/user_setting'
155
157
  autoload :KlassShare, 'labimotion/models/klass_share'
156
158
 
157
159
  autoload :CellLine, 'labimotion/models/cellline'
@@ -172,7 +174,7 @@ module Labimotion
172
174
  autoload :Segmentable, 'labimotion/models/concerns/segmentable'
173
175
  autoload :Datasetable, 'labimotion/models/concerns/datasetable'
174
176
  autoload :AttachmentConverter, 'labimotion/models/concerns/attachment_converter'
175
- autoload :ProfileAiSettings, 'labimotion/models/concerns/profile_ai_settings'
177
+ autoload :MatriceLabimotion, 'labimotion/models/concerns/matrice_labimotion'
176
178
  autoload :LinkedProperties, 'labimotion/models/concerns/linked_properties'
177
179
  autoload :TemplateDoi, 'labimotion/models/concerns/template_doi'
178
180
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: labimotion
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.4.0.rc8
4
+ version: 2.4.0.rc9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chia-Lin Lin
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2026-08-12 00:00:00.000000000 Z
12
+ date: 2026-08-14 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: caxlsx
@@ -174,6 +174,7 @@ files:
174
174
  - lib/labimotion/libs/share_resolver.rb
175
175
  - lib/labimotion/libs/template_hub.rb
176
176
  - lib/labimotion/libs/template_matcher.rb
177
+ - lib/labimotion/libs/user_ai_settings.rb
177
178
  - lib/labimotion/libs/vocabulary_handler.rb
178
179
  - lib/labimotion/libs/xlsx_exporter.rb
179
180
  - lib/labimotion/models/cellline.rb
@@ -186,8 +187,8 @@ files:
186
187
  - lib/labimotion/models/concerns/generic_revisions.rb
187
188
  - lib/labimotion/models/concerns/klass_revision.rb
188
189
  - lib/labimotion/models/concerns/linked_properties.rb
190
+ - lib/labimotion/models/concerns/matrice_labimotion.rb
189
191
  - lib/labimotion/models/concerns/metadata_validation.rb
190
- - lib/labimotion/models/concerns/profile_ai_settings.rb
191
192
  - lib/labimotion/models/concerns/segmentable.rb
192
193
  - lib/labimotion/models/concerns/template_doi.rb
193
194
  - lib/labimotion/models/concerns/workflow.rb
@@ -220,6 +221,7 @@ files:
220
221
  - lib/labimotion/models/std_layers_revision.rb
221
222
  - lib/labimotion/models/template_submission.rb
222
223
  - lib/labimotion/models/user_klass_setting.rb
224
+ - lib/labimotion/models/user_setting.rb
223
225
  - lib/labimotion/models/vocabulary.rb
224
226
  - lib/labimotion/models/wellplate.rb
225
227
  - lib/labimotion/usecases/build_template_doi_xml.rb
@@ -1,116 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Labimotion
4
- ## Per-user LabIMotion AI settings, stored on the host's profile record.
5
- #
6
- # The model, an optional personal API key and an optional provider endpoint of
7
- # one's own live under one key in the host's `data` jsonb. They sit on the
8
- # profile rather than in a table of ours because the credential belongs to the
9
- # person, not to LabIMotion — but the shape of the value is ours, so reading
10
- # and writing it belongs here rather than in each host.
11
- #
12
- # The API key is stored ENCRYPTED and never returned to a client: only whether
13
- # one is set. Encryption stays the HOST's — key management is a deployment
14
- # concern and a gem has no business inventing one. Chemotion supplies it as an
15
- # `Encryptor` concern, which is pulled in below when present, so a host there
16
- # includes this one alone:
17
- #
18
- # class Profile < ApplicationRecord
19
- # include Labimotion::ProfileAiSettings
20
- # end
21
- #
22
- # A host that names its encryption differently just defines `encrypt_value`
23
- # and `decrypt_value` on the model itself.
24
- module ProfileAiSettings
25
- extend ActiveSupport::Concern
26
-
27
- DATA_KEY = 'labimotion_ai'
28
-
29
- included do
30
- # Reference, not `defined?`: under Zeitwerk the latter reads nil until a
31
- # constant has been touched, while referencing it triggers the autoload.
32
- # A host without an Encryptor supplies the two methods itself.
33
- # rubocop:disable Layout/EmptyLinesAfterModuleInclusion -- the blank line
34
- # this wants collides with EmptyLinesAroundExceptionHandlingKeywords.
35
- begin
36
- include Encryptor
37
- rescue NameError
38
- nil
39
- end
40
- # rubocop:enable Layout/EmptyLinesAfterModuleInclusion
41
-
42
- # Kept as a constant on the including class too: hosts reference it to
43
- # strip these settings out of profile payloads, which is how the encrypted
44
- # key stays out of every response that is not this feature's own.
45
- const_set(:LABIMOTION_AI_DATA_KEY, DATA_KEY) unless const_defined?(:LABIMOTION_AI_DATA_KEY)
46
- end
47
-
48
- def labimotion_ai_settings
49
- (data || {})[DATA_KEY] || {}
50
- end
51
-
52
- def labimotion_ai_model
53
- labimotion_ai_settings['model'].presence
54
- end
55
-
56
- # Personal provider endpoint (BYO-Provider). Honored only alongside a
57
- # personal key, and SSRF-validated by Labimotion::AiEgressGuard before any
58
- # request is made.
59
- def labimotion_ai_base_url
60
- labimotion_ai_settings['base_url'].presence
61
- end
62
-
63
- def labimotion_ai_api_path
64
- labimotion_ai_settings['api_path'].presence
65
- end
66
-
67
- def labimotion_ai_api_key?
68
- labimotion_ai_settings['api_key'].present?
69
- end
70
-
71
- # Decrypted personal API key, or nil when none is stored.
72
- def labimotion_ai_api_key
73
- enc = labimotion_ai_settings['api_key']
74
- return nil if enc.blank?
75
-
76
- decrypt_value(enc).presence
77
- end
78
-
79
- # Persist the settings. For every field: nil leaves it unchanged, a blank
80
- # string clears it, anything else is stored (the api_key encrypted).
81
- # Returns the safe summary — never the key itself.
82
- def update_labimotion_ai(model: nil, api_key: nil, base_url: nil, api_path: nil)
83
- ai = labimotion_ai_settings.dup
84
- ai['model'] = model.to_s if model
85
- unless api_key.nil?
86
- if api_key.to_s.strip.blank?
87
- ai.delete('api_key')
88
- else
89
- ai['api_key'] = encrypt_value(api_key.to_s.strip)
90
- end
91
- end
92
- set_or_clear_labimotion_ai(ai, 'base_url', base_url)
93
- set_or_clear_labimotion_ai(ai, 'api_path', api_path)
94
- # Reassign the whole `data` hash rather than mutating in place, so the
95
- # jsonb column is reliably marked dirty and actually persisted.
96
- self.data = (data || {}).merge(DATA_KEY => ai)
97
- save!
98
- { model: ai['model'], api_key_set: ai['api_key'].present?,
99
- base_url: ai['base_url'], api_path: ai['api_path'] }
100
- end
101
-
102
- private
103
-
104
- # nil -> leave unchanged; blank string -> clear; else store the trimmed value.
105
- def set_or_clear_labimotion_ai(settings, key, value)
106
- return if value.nil?
107
-
108
- trimmed = value.to_s.strip
109
- if trimmed.blank?
110
- settings.delete(key)
111
- else
112
- settings[key] = trimmed
113
- end
114
- end
115
- end
116
- end