labimotion 2.4.0.rc8 → 2.4.0.rc10

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: 6355c96e00c582cf1b874e6e4300bbb26a150f276e3afcde63e364e50a7b4f31
4
+ data.tar.gz: 69b3c2d5fa16f295ee2dbdf4b240b0948e1e1f05a3a3edb8c4c4881fecda1137
5
5
  SHA512:
6
- metadata.gz: fbd4bc4b60468e2ffabfd9da96d9425e3aa718a305747c794f35bd63d27ae3e0d630ee90d68b37a5ad353e307b692c41364ff905947b9b623f9d75a5d9dcedc9
7
- data.tar.gz: 5062fc57240fb3319a7a2a272fccd20eee6d379910c7b14c7ff9a0ca975d1920f0dc019a812873fa173796d8ad3e26bced01610f48d296b7e6d3d2d8787dff8a
6
+ metadata.gz: a916242cee0543be6fa0fa04333eaa9dfede1d486f311e0229b97f13120e18b80e58bdbfac234b0429a09e2ba915bafe461be229a52cc64bde1ecd8fd403a597
7
+ data.tar.gz: f79f17d2d887a637566ba9bd81123cb1474c75dcb6519813b2dcb770888e1c49c7b55342899f2455423e2a46da3d6056d27229048b431b2fa9c78595c9acfd41
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.
@@ -107,6 +107,43 @@ module Labimotion
107
107
  end
108
108
  end
109
109
 
110
+ namespace :plan_ai_klass do
111
+ # The cheap half of fine-tuning: an instruction plus an INDEX of the open
112
+ # template in, a short list of structural operations out, which the
113
+ # designer applies with the handlers it already has. Type-agnostic like
114
+ # :refine_ai_klass beside it — the path reads as dataset only because
115
+ # that is where this family of routes was first added.
116
+ #
117
+ # Gated on the same whitelist as the AI settings API
118
+ # (Labimotion::MatriceLabimotion, via the host's model): this endpoint
119
+ # spends the user's AI budget, so reaching it needs more than being
120
+ # logged in. NameError, not defined?, for the reason spelled out at
121
+ # generic_element_api's :ai_fill_data — under Zeitwerk `defined?` is nil
122
+ # until the constant is first referenced, and a host that ships no such
123
+ # model is a refusal too.
124
+ before do
125
+ allowed = begin
126
+ Matrice.ai_enabled_for_any?(current_user)
127
+ rescue NameError
128
+ false
129
+ end
130
+ unless allowed
131
+ error!({ status: 'error', message: 'AI template editing is not enabled for this account.' }, 403)
132
+ end
133
+ end
134
+
135
+ desc 'plan structural changes to a template with AI (returns operations for the designer to apply)'
136
+ params do
137
+ use :plan_ai_dataset_klass_params
138
+ end
139
+ post do
140
+ plan_ai_dataset_klass(params, current_user)
141
+ rescue StandardError => e
142
+ Labimotion.log_exception(e, current_user)
143
+ { status: 'error', message: e.message }
144
+ end
145
+ end
146
+
110
147
  namespace :find_template do
111
148
  desc 'Find best matching template for given OLS term ID'
112
149
  params do
@@ -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)
@@ -144,18 +144,67 @@ module Labimotion
144
144
  cols: params[:cols],
145
145
  **overrides
146
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
- }
147
+ refine_outcome(ai, params[:label])
154
148
  rescue StandardError => e
155
149
  Labimotion.log_exception(e, current_user)
156
150
  { status: 'error', message: e.message }
157
151
  end
158
152
 
153
+ # Turn an instruction into a list of STRUCTURAL operations the designer applies
154
+ # itself, from an INDEX of the open template rather than the template. A layout
155
+ # change then costs what the sentence costs, not what the template costs.
156
+ #
157
+ # Four outcomes, three of them normal:
158
+ # 'success' — operations to apply
159
+ # 'design' — not expressible as operations; the client re-asks on refine
160
+ # 'unrelated' — not a template change at all; the turn STOPS here
161
+ # 'error' — the request failed
162
+ # 'design' is deliberately not an error: it is the routing answer for every
163
+ # instruction that needs new fields, wording, units or ontology terms.
164
+ # 'unrelated' exists so an off-topic message does not fall through to refine,
165
+ # where the whole template would be re-emitted to report that nothing changed.
166
+ def plan_ai_dataset_klass(params, current_user)
167
+ instruction = params[:instruction].to_s.strip
168
+ raise 'An instruction is required' if instruction.blank?
169
+
170
+ overrides = ai_user_overrides(current_user)
171
+ ai = Labimotion::AiTemplate.plan(
172
+ index: params[:index] || {},
173
+ instruction: instruction,
174
+ ols_term_id: ai_term_with_label(params[:ols_term_id]),
175
+ history: params[:history],
176
+ **overrides
177
+ )
178
+ plan_outcome(ai)
179
+ rescue StandardError => e
180
+ Labimotion.log_exception(e, current_user)
181
+ { status: 'error', message: e.message }
182
+ end
183
+
184
+ def refine_outcome(refined, fallback_label)
185
+ {
186
+ status: 'success',
187
+ label: refined['label'].presence || fallback_label,
188
+ layers: refined['layers'],
189
+ select_options: refined['select_options'],
190
+ summary: refined['summary'],
191
+ usage: refined['usage'],
192
+ model: refined['model']
193
+ }
194
+ end
195
+
196
+ # Which of the three normal outcomes this plan is. `model` rides on all of
197
+ # them: it is what actually answered, which is not always what the user
198
+ # picked — a keyless user's choice is clamped to the server allowlist.
199
+ def plan_outcome(plan)
200
+ base = { reason: plan['reason'], usage: plan['usage'], model: plan['model'] }
201
+ return base.merge(status: 'unrelated') if plan['unrelated']
202
+ return base.merge(status: 'design') if plan['needs_design']
203
+
204
+ { status: 'success', operations: plan['operations'], summary: plan['summary'],
205
+ usage: plan['usage'], model: plan['model'] }
206
+ end
207
+
159
208
  def find_best_match_template(ols_term_id)
160
209
  result = Labimotion::TemplateMatcher.find_best_match(ols_term_id)
161
210
  if result[:template]
@@ -141,7 +141,7 @@ module Labimotion
141
141
  instructions: ai_fill_instructions(element),
142
142
  **overrides
143
143
  )
144
- { status: 'success', values: ai['values'], summary: ai['summary'] }
144
+ { status: 'success', values: ai['values'], summary: ai['summary'], usage: ai['usage'], model: ai['model'] }
145
145
  rescue StandardError => e
146
146
  Labimotion.log_exception(e, current_user)
147
147
  { status: 'error', message: e.message }
@@ -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]
@@ -104,6 +104,19 @@ module Labimotion
104
104
  end
105
105
  end
106
106
 
107
+ params :plan_ai_dataset_klass_params do
108
+ requires :instruction, type: String, desc: 'Natural-language change to apply to the template'
109
+ optional :ols_term_id, type: String, desc: 'CHMO ontology term (context only)'
110
+ # The INDEX, not the template: layer/field keys, labels and types only. Left
111
+ # as an opaque Hash because the gem only relays it — the client builds it and
112
+ # the client consumes the plan that comes back.
113
+ optional :index, type: Hash, desc: 'Compact index of the open template (keys, labels, types, groups)'
114
+ optional :history, type: Array, desc: 'Prior chat turns for continuity' do
115
+ optional :role, type: String, desc: 'user | assistant'
116
+ optional :content, type: String, desc: 'Message content'
117
+ end
118
+ end
119
+
107
120
  params :update_element_klass_params do
108
121
  requires :id, type: Integer, desc: 'Element Klass ID'
109
122
  optional :label, type: String, desc: 'Element Klass Label'