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 +4 -4
- data/CHANGELOG.md +3 -0
- data/lib/labimotion/apis/generic_dataset_api.rb +37 -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/dataset_helpers.rb +56 -7
- data/lib/labimotion/helpers/element_helpers.rb +1 -1
- data/lib/labimotion/helpers/generic_helpers.rb +27 -19
- data/lib/labimotion/helpers/param_helpers.rb +13 -0
- data/lib/labimotion/libs/ai_template.rb +427 -26
- 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: 6355c96e00c582cf1b874e6e4300bbb26a150f276e3afcde63e364e50a7b4f31
|
|
4
|
+
data.tar.gz: 69b3c2d5fa16f295ee2dbdf4b240b0948e1e1f05a3a3edb8c4c4881fecda1137
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
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)
|
|
@@ -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)
|
|
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]
|
|
@@ -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'
|