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 +4 -4
- data/CHANGELOG.md +3 -0
- data/lib/labimotion/apis/generic_element_api.rb +8 -7
- data/lib/labimotion/apis/klass_share_api.rb +14 -12
- data/lib/labimotion/apis/labimotion_ai_api.rb +28 -22
- data/lib/labimotion/apis/user_klass_settings_api.rb +4 -4
- data/lib/labimotion/constants.rb +18 -1
- data/lib/labimotion/entities/generic_klass_entity.rb +1 -1
- data/lib/labimotion/helpers/generic_helpers.rb +27 -19
- data/lib/labimotion/libs/ai_template.rb +64 -16
- data/lib/labimotion/libs/share_resolver.rb +10 -6
- data/lib/labimotion/libs/user_ai_settings.rb +129 -0
- data/lib/labimotion/models/concerns/matrice_labimotion.rb +124 -0
- data/lib/labimotion/models/klass_share.rb +7 -4
- data/lib/labimotion/models/user_klass_setting.rb +2 -2
- data/lib/labimotion/models/user_setting.rb +60 -0
- data/lib/labimotion/version.rb +1 -1
- data/lib/labimotion.rb +3 -1
- metadata +5 -3
- data/lib/labimotion/models/concerns/profile_ai_settings.rb +0 -116
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c6b27ce7b185d248cf78a19fbeb00cf5c60edeed1e090d1fa73c2de8d4465579
|
|
4
|
+
data.tar.gz: dd64b3fd5e1fbb717135444ed666df30e2ae542e6e65fb2a3616b210ab9d5832
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
238
|
-
#
|
|
239
|
-
#
|
|
240
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
|
304
|
-
# the routes are built, and referencing the ActiveRecord model there would
|
|
305
|
-
# to load in any process that only mounts the API. Owner (30) is absent on
|
|
306
|
-
|
|
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:
|
|
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
|
|
346
|
-
# the routes are built, and referencing the ActiveRecord model there would
|
|
347
|
-
# to load in any process that only mounts the API. Owner (30) is absent on
|
|
348
|
-
|
|
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
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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,
|
|
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
|
-
|
|
131
|
+
settings = ai_settings
|
|
126
132
|
configured = Rails.configuration.respond_to?(:labimotion_ai) ? Rails.configuration.labimotion_ai : nil
|
|
127
133
|
{
|
|
128
|
-
model:
|
|
129
|
-
api_key_set:
|
|
130
|
-
base_url:
|
|
131
|
-
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
210
|
-
base_url:
|
|
211
|
-
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
|
-
|
|
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:
|
|
226
|
-
api_key:
|
|
227
|
-
base_url:
|
|
228
|
-
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::
|
|
24
|
-
attrs = settings[Labimotion::Constants::
|
|
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::
|
|
26
|
+
result[Labimotion::Constants::UserKlassSetting::TOOLBAR] = toolbar if toolbar.is_a?(Hash)
|
|
27
27
|
if attrs.is_a?(Array)
|
|
28
|
-
result[Labimotion::Constants::
|
|
28
|
+
result[Labimotion::Constants::UserKlassSetting::LINKED_EL_ATTRS] = normalize_linked_el_attrs(attrs)
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
31
|
end
|
data/lib/labimotion/constants.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
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
|
-
|
|
119
|
-
return {} if profile.nil?
|
|
122
|
+
return {} if current_user.nil?
|
|
120
123
|
|
|
121
|
-
|
|
122
|
-
api_key =
|
|
123
|
-
model = ai_shared_key_model(model)
|
|
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(
|
|
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(
|
|
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"
|
|
209
|
-
#
|
|
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
|
-
|
|
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
|
|
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 = '
|
|
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
|
|
1365
|
-
above
|
|
1366
|
-
|
|
1367
|
-
|
|
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
|
-
#
|
|
1484
|
-
#
|
|
1485
|
-
#
|
|
1486
|
-
|
|
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(
|
|
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).
|
|
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.
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
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,
|
|
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
|
|
35
|
-
# threshold checks valid. Spaced by ten so a future level is additive
|
|
36
|
-
#
|
|
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::
|
|
22
|
-
LINKED_EL_ATTRS = Labimotion::Constants::
|
|
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
|
data/lib/labimotion/version.rb
CHANGED
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 :
|
|
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.
|
|
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
|
+
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
|