labimotion 2.4.0.rc2 → 2.4.0.rc4
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 +9 -0
- data/lib/labimotion/apis/generic_dataset_api.rb +41 -3
- data/lib/labimotion/apis/generic_element_api.rb +60 -8
- data/lib/labimotion/apis/generic_klass_api.rb +50 -6
- data/lib/labimotion/apis/klass_share_api.rb +646 -0
- data/lib/labimotion/apis/labimotion_api.rb +1 -0
- data/lib/labimotion/apis/labimotion_doi_api.rb +24 -10
- data/lib/labimotion/apis/labimotion_template_browse_api.rb +13 -1
- data/lib/labimotion/apis/segment_api.rb +61 -10
- data/lib/labimotion/constants.rb +14 -0
- data/lib/labimotion/entities/application_entity.rb +13 -1
- data/lib/labimotion/entities/generic_klass_entity.rb +125 -0
- data/lib/labimotion/entities/klass_share_entity.rb +48 -0
- data/lib/labimotion/helpers/dataset_helpers.rb +123 -1
- data/lib/labimotion/helpers/element_helpers.rb +206 -4
- data/lib/labimotion/helpers/generic_helpers.rb +276 -4
- data/lib/labimotion/helpers/param_helpers.rb +68 -0
- data/lib/labimotion/helpers/segment_helpers.rb +87 -4
- data/lib/labimotion/libs/ai_egress_guard.rb +84 -0
- data/lib/labimotion/libs/ai_template.rb +1482 -0
- data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
- data/lib/labimotion/libs/file_extractor.rb +210 -0
- data/lib/labimotion/libs/owner_resolver.rb +50 -0
- data/lib/labimotion/libs/ownership_audit.rb +73 -0
- data/lib/labimotion/libs/share_notifier.rb +114 -0
- data/lib/labimotion/libs/share_resolver.rb +369 -0
- data/lib/labimotion/models/concerns/template_doi.rb +133 -0
- data/lib/labimotion/models/dataset_klass.rb +1 -1
- data/lib/labimotion/models/element_klass.rb +1 -1
- data/lib/labimotion/models/klass_share.rb +126 -0
- data/lib/labimotion/models/segment_klass.rb +1 -1
- data/lib/labimotion/usecases/build_template_doi_xml.rb +69 -23
- data/lib/labimotion/usecases/release_template_doi.rb +42 -17
- data/lib/labimotion/usecases/template_doi_helpers.rb +28 -6
- data/lib/labimotion/usecases/update_template_publication_metadata.rb +71 -2
- data/lib/labimotion/utils/prop.rb +1 -0
- data/lib/labimotion/version.rb +1 -1
- data/lib/labimotion.rb +11 -0
- metadata +28 -2
|
@@ -31,7 +31,13 @@ module Labimotion
|
|
|
31
31
|
attributes[:properties_release] = attributes[:properties_template]
|
|
32
32
|
attributes[:created_by] = current_user.id
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
# The owner row shares the create's transaction: a klass committed without it is
|
|
35
|
+
# owner-less, which falls open to the legacy designer-wide gate.
|
|
36
|
+
new_klass = Labimotion::ElementKlass.transaction do
|
|
37
|
+
Labimotion::ElementKlass.create!(attributes).tap do |klz|
|
|
38
|
+
Labimotion::KlassShare.seed_owner!(klz, current_user.id)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
35
41
|
new_klass.reload
|
|
36
42
|
new_klass.create_klasses_revision(current_user)
|
|
37
43
|
klass_names_file = Labimotion::KLASSES_JSON # Rails.root.join('app/packs/klasses.json')
|
|
@@ -43,6 +49,184 @@ module Labimotion
|
|
|
43
49
|
raise e
|
|
44
50
|
end
|
|
45
51
|
|
|
52
|
+
# Create a new (inactive) element klass whose properties template is
|
|
53
|
+
# generated by an LLM from a user-provided name/label plus optional
|
|
54
|
+
# description, reference links and uploaded files. The admin reviews/edits
|
|
55
|
+
# the generated template in the designer and activates it (human-in-the-loop).
|
|
56
|
+
def create_ai_element_klass(params, current_user)
|
|
57
|
+
name = params[:name]
|
|
58
|
+
label = params[:label]
|
|
59
|
+
if Labimotion::ElementKlass.find_by(name: name).present?
|
|
60
|
+
return { status: 'error', message: "An element named [#{name}] already exists." }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if Array(params[:files]).size > Labimotion::AiTemplate::MAX_FILES
|
|
64
|
+
return { status: 'error', message: "Too many files (max #{Labimotion::AiTemplate::MAX_FILES})." }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
overrides = ai_user_overrides(current_user)
|
|
68
|
+
ai = Labimotion::AiTemplate.generate(
|
|
69
|
+
kind: 'element',
|
|
70
|
+
subject: "#{params[:label]} (#{params[:name]})",
|
|
71
|
+
desc: params[:desc],
|
|
72
|
+
cols: params[:cols],
|
|
73
|
+
references: params[:references],
|
|
74
|
+
files: params[:files],
|
|
75
|
+
**overrides
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
uuid = SecureRandom.uuid
|
|
79
|
+
# property-base schema requires pkg, uuid, klass, layers, version, identifier.
|
|
80
|
+
properties_template = {
|
|
81
|
+
'uuid' => uuid,
|
|
82
|
+
'klass' => 'ElementKlass',
|
|
83
|
+
'pkg' => Labimotion::Utils.pkg(nil),
|
|
84
|
+
'version' => '1.0.0',
|
|
85
|
+
'identifier' => uuid,
|
|
86
|
+
'layers' => ai['layers'],
|
|
87
|
+
'select_options' => ai['select_options'],
|
|
88
|
+
'metadata' => ai['metadata']
|
|
89
|
+
}
|
|
90
|
+
attributes = {
|
|
91
|
+
'name' => name,
|
|
92
|
+
'label' => label,
|
|
93
|
+
'klass_prefix' => params[:klass_prefix],
|
|
94
|
+
'icon_name' => params[:icon_name],
|
|
95
|
+
'desc' => params[:desc].presence || ai['label'].presence,
|
|
96
|
+
'is_active' => false,
|
|
97
|
+
'uuid' => uuid,
|
|
98
|
+
'released_at' => DateTime.now,
|
|
99
|
+
'properties_template' => properties_template,
|
|
100
|
+
'properties_release' => properties_template,
|
|
101
|
+
'created_by' => current_user.id
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
new_klass = Labimotion::ElementKlass.create!(attributes)
|
|
105
|
+
new_klass.reload
|
|
106
|
+
new_klass.create_klasses_revision(current_user)
|
|
107
|
+
klass_names_file = Labimotion::KLASSES_JSON # Rails.root.join('app/packs/klasses.json')
|
|
108
|
+
klasses = Labimotion::ElementKlass.where(is_active: true)&.pluck(:name) || []
|
|
109
|
+
File.write(klass_names_file, klasses)
|
|
110
|
+
{ status: 'success',
|
|
111
|
+
message: "The AI element template [#{label}] has been created as inactive. Review and activate it in the designer." }
|
|
112
|
+
rescue StandardError => e
|
|
113
|
+
Labimotion.log_exception(e, current_user)
|
|
114
|
+
{ status: 'error', message: e.message }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# AI auto-fill of a generic element instance's DATA VALUES from a document
|
|
118
|
+
# (an existing attachment, an analysis, or freshly uploaded files). Reads the
|
|
119
|
+
# source text, asks the LLM to extract values for the element's OWN template
|
|
120
|
+
# fields, validates them, and returns them for the client to merge into the
|
|
121
|
+
# working copy for human review. Nothing is persisted here.
|
|
122
|
+
#
|
|
123
|
+
# Authorization: the caller must be able to READ the element, and the source
|
|
124
|
+
# must BELONG to that element (see the ownership checks below) — this endpoint
|
|
125
|
+
# reads files by id, so it must never read an Attachment/Container the user
|
|
126
|
+
# does not already have access to through this element.
|
|
127
|
+
def ai_fill_element_data(params, current_user)
|
|
128
|
+
element = Labimotion::Element.find(params[:element_id])
|
|
129
|
+
error!('401 Unauthorized', 401) unless ElementPolicy.new(current_user, element).read?
|
|
130
|
+
|
|
131
|
+
text = ai_fill_source_text(element, params)
|
|
132
|
+
overrides = ai_user_overrides(current_user)
|
|
133
|
+
ai = Labimotion::AiTemplate.fill(
|
|
134
|
+
properties: element.properties,
|
|
135
|
+
context_text: text,
|
|
136
|
+
**overrides
|
|
137
|
+
)
|
|
138
|
+
{ status: 'success', values: ai['values'], summary: ai['summary'] }
|
|
139
|
+
rescue StandardError => e
|
|
140
|
+
Labimotion.log_exception(e, current_user)
|
|
141
|
+
{ status: 'error', message: e.message }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Resolve the requested source into plain text, enforcing ownership first.
|
|
145
|
+
def ai_fill_source_text(element, params)
|
|
146
|
+
case params[:source]
|
|
147
|
+
when 'attachment'
|
|
148
|
+
ai_fill_attachment_text(element, params[:attachment_id])
|
|
149
|
+
when 'analysis'
|
|
150
|
+
ai_fill_analysis_text(element, params[:container_id])
|
|
151
|
+
when 'upload'
|
|
152
|
+
ai_fill_upload_text(params[:files])
|
|
153
|
+
else
|
|
154
|
+
error!('400 Bad Request', 400)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The attachment must be one of the element's own attachments OR an attachment
|
|
159
|
+
# of one of the element's analysis DATASET containers. Only then is it read.
|
|
160
|
+
def ai_fill_attachment_text(element, attachment_id)
|
|
161
|
+
error!('404 Not Found', 404) if attachment_id.nil?
|
|
162
|
+
error!('404 Not Found', 404) unless ai_fill_element_attachment_ids(element).include?(attachment_id.to_i)
|
|
163
|
+
|
|
164
|
+
att = Attachment.find(attachment_id)
|
|
165
|
+
Labimotion::FileExtractor.extract_bytes(att.filename, att.read_file)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The container id must be one of the element's own analysis containers. Then
|
|
169
|
+
# concatenate the extracted text of every attachment on that analysis's dataset
|
|
170
|
+
# children, each prefixed "### <filename>", with the analysis name/description
|
|
171
|
+
# prepended as context. Cap at AiTemplate::MAX_FILES; skip unreadable files.
|
|
172
|
+
def ai_fill_analysis_text(element, container_id)
|
|
173
|
+
error!('404 Not Found', 404) if container_id.nil?
|
|
174
|
+
analysis = element.analyses.detect { |c| c.id == container_id.to_i }
|
|
175
|
+
error!('404 Not Found', 404) if analysis.nil?
|
|
176
|
+
|
|
177
|
+
parts = []
|
|
178
|
+
context = [analysis.name.presence, analysis.description.presence].compact.join(' — ')
|
|
179
|
+
parts << "Analysis: #{context}" if context.present?
|
|
180
|
+
|
|
181
|
+
ai_fill_analysis_attachments(analysis).first(Labimotion::AiTemplate::MAX_FILES).each do |att|
|
|
182
|
+
content = ai_fill_extract_attachment(att)
|
|
183
|
+
next if content.to_s.strip.empty?
|
|
184
|
+
|
|
185
|
+
parts << "### #{att.filename}\n#{content}"
|
|
186
|
+
end
|
|
187
|
+
parts.join("\n\n")
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def ai_fill_upload_text(files)
|
|
191
|
+
parts = Array(files).first(Labimotion::AiTemplate::MAX_FILES).map do |file|
|
|
192
|
+
name = file[:filename] || file['filename']
|
|
193
|
+
next if name.blank?
|
|
194
|
+
|
|
195
|
+
content = Labimotion::FileExtractor.extract(name, file[:content_base64] || file['content_base64'])
|
|
196
|
+
next if content.to_s.strip.empty?
|
|
197
|
+
|
|
198
|
+
"### #{name}\n#{content}"
|
|
199
|
+
end.compact
|
|
200
|
+
parts.join("\n\n")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Ids of attachments this element may expose to the AI fill: its own
|
|
204
|
+
# attachments plus the attachments of its analysis dataset containers.
|
|
205
|
+
def ai_fill_element_attachment_ids(element)
|
|
206
|
+
own = element.attachments.pluck(:id)
|
|
207
|
+
dataset_ids = ai_fill_dataset_containers(element).map(&:id)
|
|
208
|
+
dataset_att = dataset_ids.empty? ? [] : Attachment.where(attachable_type: 'Container', attachable_id: dataset_ids).pluck(:id)
|
|
209
|
+
(own + dataset_att).uniq
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Dataset containers under all of the element's analysis containers.
|
|
213
|
+
def ai_fill_dataset_containers(element)
|
|
214
|
+
element.analyses.flat_map { |analysis| analysis.children.where(container_type: 'dataset').to_a }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def ai_fill_analysis_attachments(analysis)
|
|
218
|
+
analysis.children.where(container_type: 'dataset').flat_map do |dataset|
|
|
219
|
+
Attachment.where(attachable_type: 'Container', attachable_id: dataset.id).to_a
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def ai_fill_extract_attachment(att)
|
|
224
|
+
Labimotion::FileExtractor.extract_bytes(att.filename, att.read_file)
|
|
225
|
+
rescue StandardError => e
|
|
226
|
+
Labimotion.log_exception(e)
|
|
227
|
+
nil
|
|
228
|
+
end
|
|
229
|
+
|
|
46
230
|
def update_element_klass(current_user, params)
|
|
47
231
|
place = params[:place] || 100
|
|
48
232
|
begin
|
|
@@ -51,6 +235,7 @@ module Labimotion
|
|
|
51
235
|
place = 100
|
|
52
236
|
end
|
|
53
237
|
klass = Labimotion::ElementKlass.find(params[:id])
|
|
238
|
+
authorize_klass!(klass, :write)
|
|
54
239
|
klass.label = params[:label] if params[:label].present?
|
|
55
240
|
klass.klass_prefix = params[:klass_prefix] if params[:klass_prefix].present?
|
|
56
241
|
klass.icon_name = params[:icon_name] if params[:icon_name].present?
|
|
@@ -305,12 +490,25 @@ module Labimotion
|
|
|
305
490
|
end
|
|
306
491
|
|
|
307
492
|
def validate_klass(attributes)
|
|
308
|
-
|
|
493
|
+
# Guarded on presence like the segment path: a blank identifier must mean "create",
|
|
494
|
+
# never `find_by(identifier: nil)` — that would match an arbitrary legacy row whose
|
|
495
|
+
# identifier is NULL and upgrade an unrelated template.
|
|
496
|
+
element_klass = Labimotion::ElementKlass.find_by(identifier: attributes['identifier']) if attributes['identifier'].present?
|
|
309
497
|
if element_klass.present?
|
|
310
498
|
if element_klass['uuid'] == attributes['uuid'] && element_klass['version'] == attributes['version']
|
|
311
499
|
return { status: 'success', message: "This element: #{attributes['name']} has the latest version!" }
|
|
312
500
|
else
|
|
313
|
-
|
|
501
|
+
# Upgrading an existing template bumps its version and cuts a revision — publishing,
|
|
502
|
+
# which is what :release means, so it is owner-only. The gate has to live here, not
|
|
503
|
+
# in after_validation: which branch applies is unknown until this identifier lookup
|
|
504
|
+
# has run. error! throws rather than raises, so it escapes this method's rescue.
|
|
505
|
+
authorize_klass!(element_klass, :release)
|
|
506
|
+
# And it must not reassign authorship. `upload_klass` merges
|
|
507
|
+
# `created_by: current_user.id` into the attributes unconditionally
|
|
508
|
+
# (generic_element_api.rb), so without this the importer silently becomes the author
|
|
509
|
+
# of a template somebody else wrote — and that column is what the Owner column shows
|
|
510
|
+
# and what per-template ownership is seeded from.
|
|
511
|
+
element_klass.update!(attributes.except('created_by', :created_by))
|
|
314
512
|
element_klass.create_klasses_revision(current_user)
|
|
315
513
|
return { status: 'success', message: "This element: [#{attributes['name']}] has been upgraded to the version: #{attributes['version']}!" }
|
|
316
514
|
end
|
|
@@ -320,7 +518,11 @@ module Labimotion
|
|
|
320
518
|
return { status: 'error', message: "The name [#{attributes['name']}] is already in use." }
|
|
321
519
|
else
|
|
322
520
|
attributes['created_by'] = current_user.id
|
|
323
|
-
element_klass = Labimotion::ElementKlass.
|
|
521
|
+
element_klass = Labimotion::ElementKlass.transaction do
|
|
522
|
+
Labimotion::ElementKlass.create!(attributes).tap do |klz|
|
|
523
|
+
Labimotion::KlassShare.seed_owner!(klz, current_user.id)
|
|
524
|
+
end
|
|
525
|
+
end
|
|
324
526
|
element_klass.create_klasses_revision(current_user)
|
|
325
527
|
return { status: 'success', message: "The element: #{attributes['name']} has been created using version: #{attributes['version']}!" }
|
|
326
528
|
end
|
|
@@ -1,22 +1,105 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require 'grape'
|
|
3
3
|
require 'labimotion/conf'
|
|
4
|
+
require 'labimotion/constants'
|
|
4
5
|
require 'labimotion/utils/utils'
|
|
6
|
+
require 'labimotion/libs/owner_resolver'
|
|
5
7
|
# Helper for associated sample
|
|
6
8
|
module Labimotion
|
|
7
9
|
## Generic Helpers
|
|
8
10
|
module GenericHelpers
|
|
9
11
|
extend Grape::API::Helpers
|
|
10
12
|
|
|
13
|
+
# Message code for "somebody else saved this template while you were editing it".
|
|
14
|
+
STALE_TEMPLATE_MC = 'sc09'
|
|
15
|
+
# Message code for "an active template already holds the identity you are restoring".
|
|
16
|
+
RESTORE_CONFLICT_MC = 'sc10'
|
|
17
|
+
# Message code for "this template is maintained by somebody else" — the 403 a share-gated
|
|
18
|
+
# action answers with. Deliberately not the legacy 401: nobody here is unauthorised, they
|
|
19
|
+
# are asking about a colleague's template (template-sharing.md §3).
|
|
20
|
+
AUTHORIZATION_REFUSAL_MC = 'sc11'
|
|
21
|
+
# Message code for "the owner row cannot be removed or downgraded — transfer instead".
|
|
22
|
+
OWNER_ROW_MC = 'sc12'
|
|
23
|
+
# Message code for "you already hold access to this template, so there is nothing to
|
|
24
|
+
# request". The 409 request_access answers a caller whose share row is already at
|
|
25
|
+
# viewer/editor/owner — a stale grid, not a mistake worth an error banner.
|
|
26
|
+
ACCESS_ALREADY_HELD_MC = 'sc13'
|
|
27
|
+
|
|
28
|
+
# How a refusal names the action it refused, and which actions are owner-only for the
|
|
29
|
+
# wording. Kept as plain symbols here rather than reading ShareResolver::REQUIRED_LEVEL —
|
|
30
|
+
# the wording must not chain-load the ActiveRecord model in a process that only wanted
|
|
31
|
+
# the helpers. ShareResolver stays the enforcement authority.
|
|
32
|
+
ACTION_PHRASE = {
|
|
33
|
+
read: 'view', write: 'edit', release: 'release',
|
|
34
|
+
deactivate: 'activate or deactivate', destroy: 'delete', manage: 'manage sharing for'
|
|
35
|
+
}.freeze
|
|
36
|
+
OWNER_ONLY_ACTIONS = %i[release deactivate destroy manage].freeze
|
|
37
|
+
|
|
38
|
+
# What makes a klass unique among the *active* rows. These are model validations only —
|
|
39
|
+
# there is no DB unique index behind any of them — and `restore!` writes through
|
|
40
|
+
# `update_columns`, so nothing re-checks them on the way back in.
|
|
41
|
+
KLASS_IDENTITY = {
|
|
42
|
+
'ElementKlass' => %i[name],
|
|
43
|
+
'SegmentKlass' => %i[label element_klass_id],
|
|
44
|
+
'DatasetKlass' => %i[ols_term_id]
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
# How the refusal names the identity that is taken.
|
|
48
|
+
KLASS_IDENTITY_NOUN = {
|
|
49
|
+
'ElementKlass' => 'name',
|
|
50
|
+
'SegmentKlass' => 'label',
|
|
51
|
+
'DatasetKlass' => 'ontology term'
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
# Seconds, symmetric around the deleted template's own `deleted_at`, within which a
|
|
55
|
+
# soft-deleted child counts as "deleted together with it" (see Paranoia#restore!).
|
|
56
|
+
# Wide enough to cover a slow cascade over a template with many elements, narrow enough
|
|
57
|
+
# that rows binned on their own, on another day, stay binned.
|
|
58
|
+
RESTORE_RECOVERY_WINDOW = 300
|
|
59
|
+
|
|
11
60
|
def authenticate_admin!(type)
|
|
12
61
|
unauthorized = -> { error!('401 Unauthorized', 401) }
|
|
13
62
|
if %w[standard_layers vocabularies].include?(type)
|
|
14
|
-
unauthorized.call unless current_user.generic_admin.values_at(
|
|
63
|
+
unauthorized.call unless current_user.generic_admin.values_at(*Labimotion::Constants::Family::ALL).any?
|
|
15
64
|
else
|
|
16
65
|
unauthorized.call unless current_user.generic_admin[type]
|
|
17
66
|
end
|
|
18
67
|
end
|
|
19
68
|
|
|
69
|
+
# Family string (Constants::Family) for a loaded klass record — what the legacy gate
|
|
70
|
+
# keys on.
|
|
71
|
+
def klass_family(klz)
|
|
72
|
+
Labimotion::Constants::Family::FAMILY_OF[klz.class.name.split('::').last]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The per-template gate (template-sharing.md §6). Takes the record, not a family string:
|
|
76
|
+
# resolution needs that klass's share rows and, for a SegmentKlass, its parent element's.
|
|
77
|
+
# Owner-less templates (and hosts that have not migrated) fall back to the legacy
|
|
78
|
+
# designer-wide gate — permanent behaviour, not a transition. A refusal is a 403 naming
|
|
79
|
+
# the owner, so the person refused knows who to ask, never the legacy bare 401.
|
|
80
|
+
def authorize_klass!(klz, action)
|
|
81
|
+
result = Labimotion::ShareResolver.authorize(klz, current_user, action)
|
|
82
|
+
return if result == :ok
|
|
83
|
+
return authenticate_admin!(klass_family(klz)) if result == :legacy
|
|
84
|
+
|
|
85
|
+
owner = Labimotion::OwnerResolver.find(result.owner_id)
|
|
86
|
+
name = owner.respond_to?(:name) && owner.name.present? ? owner.name : 'another user'
|
|
87
|
+
error!({ mc: AUTHORIZATION_REFUSAL_MC,
|
|
88
|
+
msg: authorization_refusal_msg(name, action),
|
|
89
|
+
owner: { id: result.owner_id, name: name == 'another user' ? nil : name } }, 403)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def authorization_refusal_msg(name, action)
|
|
93
|
+
requirement = if OWNER_ONLY_ACTIONS.include?(action)
|
|
94
|
+
"Only the owner can #{ACTION_PHRASE[action]} this template."
|
|
95
|
+
elsif action == :read
|
|
96
|
+
"You need shared access to #{ACTION_PHRASE[action]} this template."
|
|
97
|
+
else
|
|
98
|
+
"You need edit access to #{ACTION_PHRASE[action]} this template."
|
|
99
|
+
end
|
|
100
|
+
"This template is maintained by #{name}. #{requirement}"
|
|
101
|
+
end
|
|
102
|
+
|
|
20
103
|
def fetch_klass(name, id)
|
|
21
104
|
klz = "Labimotion::#{name}".constantize.find_by(id: id)
|
|
22
105
|
error!("#{name.gsub(/(Klass)/, '')} is invalid. Please re-select.", 500) if klz.nil?
|
|
@@ -26,8 +109,71 @@ module Labimotion
|
|
|
26
109
|
raise e
|
|
27
110
|
end
|
|
28
111
|
|
|
112
|
+
# Per-user AI overrides (model + API key) resolved from the host app's user
|
|
113
|
+
# profile. Returns {} when the host doesn't expose them, so AiTemplate falls
|
|
114
|
+
# back to the server-wide yml/ENV configuration. Guarded with respond_to? to
|
|
115
|
+
# keep the gem decoupled from the host's Profile implementation. Shared by the
|
|
116
|
+
# dataset / element / segment AI helpers.
|
|
117
|
+
def ai_user_overrides(current_user)
|
|
118
|
+
profile = current_user.respond_to?(:profile) ? current_user.profile : nil
|
|
119
|
+
return {} if profile.nil?
|
|
120
|
+
|
|
121
|
+
model = (profile.labimotion_ai_model if profile.respond_to?(:labimotion_ai_model))
|
|
122
|
+
api_key = (profile.labimotion_ai_api_key if profile.respond_to?(:labimotion_ai_api_key))
|
|
123
|
+
model = ai_shared_key_model(model) if api_key.blank?
|
|
124
|
+
|
|
125
|
+
{ model: model, api_key: api_key }.merge(ai_provider_overrides(profile, api_key)).compact
|
|
126
|
+
rescue StandardError => e
|
|
127
|
+
Labimotion.log_exception(e, current_user)
|
|
128
|
+
{}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Personal provider endpoint (base_url/api_path) — honored only ALONGSIDE a
|
|
132
|
+
# personal key, so the shared server key is never sent to a user-supplied URL.
|
|
133
|
+
# The base_url itself is SSRF-validated later, in AiTemplate.
|
|
134
|
+
def ai_provider_overrides(profile, api_key)
|
|
135
|
+
return {} if api_key.blank?
|
|
136
|
+
|
|
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
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# On the SHARED server key (user has no personal key), keep the user's model
|
|
144
|
+
# only if the admin allowed it; otherwise return nil so AiTemplate falls back
|
|
145
|
+
# to the server default. Returns the model unchanged when no allowlist is
|
|
146
|
+
# configured (fail open). Guards an off-list model being billed to the shared key.
|
|
147
|
+
def ai_shared_key_model(model)
|
|
148
|
+
return model if model.blank?
|
|
149
|
+
|
|
150
|
+
allow = ai_server_model_allowlist
|
|
151
|
+
allow.any? && !allow.include?(model.to_s) ? nil : model
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Model ids the server permits on the SHARED key: the admin pick-list
|
|
155
|
+
# (:models) plus the server default (:model / KI_TOOLBOX_MODEL), read from the
|
|
156
|
+
# host config the same way Labimotion::AiTemplate does. Empty when the admin
|
|
157
|
+
# configured neither — the caller then skips the clamp (fail open), matching
|
|
158
|
+
# the gem's decoupled, non-blocking handling of overrides.
|
|
159
|
+
def ai_server_model_allowlist
|
|
160
|
+
cfg = Rails.configuration.labimotion_ai || {}
|
|
161
|
+
list = cfg[:models].is_a?(Array) ? cfg[:models] : []
|
|
162
|
+
ids = list.map { |entry| ai_model_entry_id(entry) }
|
|
163
|
+
(ids << cfg[:model].to_s << ENV['KI_TOOLBOX_MODEL'].to_s).reject(&:blank?).uniq
|
|
164
|
+
rescue StandardError
|
|
165
|
+
[]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def ai_model_entry_id(entry)
|
|
169
|
+
return entry.to_s unless entry.is_a?(Hash)
|
|
170
|
+
|
|
171
|
+
(entry[:id] || entry['id']).to_s
|
|
172
|
+
end
|
|
173
|
+
|
|
29
174
|
def deactivate_klass(params)
|
|
30
175
|
klz = fetch_klass(params[:klass], params[:id])
|
|
176
|
+
authorize_klass!(klz, :deactivate)
|
|
31
177
|
klz&.update!(is_active: params[:is_active])
|
|
32
178
|
generate_klass_file unless klz.class.name != 'Labimotion::ElementKlass'
|
|
33
179
|
klz
|
|
@@ -38,6 +184,7 @@ module Labimotion
|
|
|
38
184
|
|
|
39
185
|
def update_klass_settings(params)
|
|
40
186
|
klz = fetch_klass(params[:klass], params[:id])
|
|
187
|
+
authorize_klass!(klz, :write)
|
|
41
188
|
klz&.update!(settings: (klz.settings || {}).merge(params[:settings] || {}))
|
|
42
189
|
klz
|
|
43
190
|
rescue StandardError => e
|
|
@@ -46,8 +193,8 @@ module Labimotion
|
|
|
46
193
|
end
|
|
47
194
|
|
|
48
195
|
def delete_klass(params)
|
|
49
|
-
authenticate_admin!(params[:klass].gsub(/(Klass)/, 's').downcase)
|
|
50
196
|
klz = fetch_klass(params[:klass], params[:id])
|
|
197
|
+
authorize_klass!(klz, :destroy)
|
|
51
198
|
klz&.destroy!
|
|
52
199
|
generate_klass_file unless klz.class.name != 'Labimotion::ElementKlass'
|
|
53
200
|
status 201
|
|
@@ -58,6 +205,11 @@ module Labimotion
|
|
|
58
205
|
|
|
59
206
|
def update_template(params, current_user)
|
|
60
207
|
klz = fetch_klass(params[:klass], params[:id])
|
|
208
|
+
# The split that makes `editor` mean "drafting only": a draft save needs :write, while
|
|
209
|
+
# anything else bumps the version and cuts a revision — publishing, which is :release.
|
|
210
|
+
# Authorization before the stale check: who may act comes before whether the copy is fresh.
|
|
211
|
+
authorize_klass!(klz, params[:release] == 'draft' ? :write : :release)
|
|
212
|
+
guard_stale_template!(klz, params[:properties_template])
|
|
61
213
|
uuid = SecureRandom.uuid
|
|
62
214
|
properties = params[:properties_template]
|
|
63
215
|
properties['uuid'] = uuid
|
|
@@ -79,12 +231,133 @@ module Labimotion
|
|
|
79
231
|
raise e
|
|
80
232
|
end
|
|
81
233
|
|
|
234
|
+
# A template save replaces `properties_template` wholesale, so two designers holding the
|
|
235
|
+
# same template open used to overwrite each other with no warning and nothing to recover
|
|
236
|
+
# from: drafts are never snapshotted (`Utils.next_version` is a no-op for 'draft' and
|
|
237
|
+
# `create_klasses_revision` is skipped), and making drafts cut revisions is not an option
|
|
238
|
+
# because cutting a revision writes `properties_release` — it publishes.
|
|
239
|
+
#
|
|
240
|
+
# `update_template` already mints a fresh uuid into the template on every save, so the
|
|
241
|
+
# stored uuid is a ready-made optimistic-locking token: the client posts back the uuid it
|
|
242
|
+
# was handed, and a difference means somebody saved in between.
|
|
243
|
+
#
|
|
244
|
+
# Blank on either side means we cannot tell — a template stored before this guard existed,
|
|
245
|
+
# or a client that dropped the field — and a template with no stored uuid must not start
|
|
246
|
+
# refusing saves.
|
|
247
|
+
def guard_stale_template!(klz, properties)
|
|
248
|
+
held = klz.properties_template.is_a?(Hash) ? klz.properties_template['uuid'] : nil
|
|
249
|
+
sent = properties.is_a?(Hash) ? properties['uuid'] : nil
|
|
250
|
+
return if held.blank? || sent.blank? || held == sent
|
|
251
|
+
|
|
252
|
+
error!(stale_template_error(klz), 409)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Naming the other designer is what turns a refusal into something a user can act on, but
|
|
256
|
+
# it is decoration: `OwnerResolver.find` returns nil rather than raising, so a lookup that
|
|
257
|
+
# fails costs the name and leaves the refusal itself intact.
|
|
258
|
+
def stale_template_error(klz)
|
|
259
|
+
saver = Labimotion::OwnerResolver.find(klz.updated_by)
|
|
260
|
+
name = saver.respond_to?(:name) ? saver.name : nil
|
|
261
|
+
name = nil if name.blank?
|
|
262
|
+
{ mc: STALE_TEMPLATE_MC,
|
|
263
|
+
msg: stale_template_msg(name, klz.updated_at),
|
|
264
|
+
saved_by: { id: klz.updated_by, name: name },
|
|
265
|
+
saved_at: klz.updated_at.blank? ? nil : klz.updated_at.iso8601 }
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def stale_template_msg(name, saved_at)
|
|
269
|
+
who = name.blank? ? 'Another user' : name
|
|
270
|
+
moment = saved_at.blank? ? 'after you opened it' : "at #{saved_at.strftime('%Y-%m-%d %H:%M %Z')}"
|
|
271
|
+
"#{who} saved this template #{moment}, so your copy is out of date; " \
|
|
272
|
+
'reload the template and re-apply your changes.'
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# `delete_klass` soft-deletes (the klasses are `acts_as_paranoid`), but nothing could list
|
|
276
|
+
# or restore the result, so recovery meant direct database access.
|
|
277
|
+
def deleted_klasses(params)
|
|
278
|
+
"Labimotion::#{params[:klass]}".constantize.only_deleted.order(deleted_at: :desc)
|
|
279
|
+
rescue StandardError => e
|
|
280
|
+
Labimotion.log_exception(e, current_user)
|
|
281
|
+
raise e
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def restore_klass(params)
|
|
285
|
+
klz = fetch_deleted_klass(params[:klass], params[:id])
|
|
286
|
+
# Restore is delete's inverse and carries the same cascade into elements, so it takes
|
|
287
|
+
# the same :destroy level. Shares survive deletion, so the owner row is still there to
|
|
288
|
+
# answer this.
|
|
289
|
+
authorize_klass!(klz, :destroy)
|
|
290
|
+
guard_restore_conflict!(params[:klass], klz)
|
|
291
|
+
# `recursive` is what brings the template's elements/segments back with it; without it
|
|
292
|
+
# paranoia restores the klass row alone and every child stays deleted. The window keeps
|
|
293
|
+
# that cascade to the children deleted *with* the template.
|
|
294
|
+
klz.restore!(recursive: true, recovery_window: RESTORE_RECOVERY_WINDOW)
|
|
295
|
+
generate_klass_file if params[:klass] == 'ElementKlass'
|
|
296
|
+
klz.reload
|
|
297
|
+
rescue StandardError => e
|
|
298
|
+
Labimotion.log_exception(e, current_user)
|
|
299
|
+
raise e
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def fetch_deleted_klass(name, id)
|
|
303
|
+
klz = "Labimotion::#{name}".constantize.only_deleted.find_by(id: id)
|
|
304
|
+
error!("#{name.gsub(/(Klass)/, '')} is not in the deleted list. Please refresh.", 404) if klz.nil?
|
|
305
|
+
klz
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# `restore!` writes through `update_columns`, so the uniqueness validations do not run, and
|
|
309
|
+
# no DB unique index backs them either. Restoring onto a taken identity would leave two
|
|
310
|
+
# active rows with the same one, and the ten `find_by(name:)` call sites in the gem — two of
|
|
311
|
+
# which pick the `properties_release` that renders real elements — would then resolve it
|
|
312
|
+
# arbitrarily. Refuse instead, and let a human decide which row keeps the name.
|
|
313
|
+
def guard_restore_conflict!(name, klz)
|
|
314
|
+
keys = KLASS_IDENTITY[name]
|
|
315
|
+
other = conflicting_active_klass(name, keys, klz)
|
|
316
|
+
return if other.nil?
|
|
317
|
+
|
|
318
|
+
error!({ mc: RESTORE_CONFLICT_MC,
|
|
319
|
+
msg: restore_conflict_msg(name, other, keys),
|
|
320
|
+
blocked_by: { id: other.id, label: klass_label(other, keys) } }, 409)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def conflicting_active_klass(name, keys, klz)
|
|
324
|
+
return nil if keys.nil?
|
|
325
|
+
|
|
326
|
+
identity = keys.to_h { |key| [key, klz.public_send(key)] }
|
|
327
|
+
return nil if identity[keys.first].blank?
|
|
328
|
+
|
|
329
|
+
# The default scope of a paranoid model excludes deleted rows, so this can only match an
|
|
330
|
+
# active one — never the row being restored.
|
|
331
|
+
"Labimotion::#{name}".constantize.where(identity).first
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def restore_conflict_msg(name, other, keys)
|
|
335
|
+
"Cannot restore: the active #{name.gsub(/(Klass)/, '')} \"#{klass_label(other, keys)}\" " \
|
|
336
|
+
"(id #{other.id}) already uses this #{KLASS_IDENTITY_NOUN[name]}."
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def klass_label(klz, keys)
|
|
340
|
+
label = klz.respond_to?(:label) ? klz.label : nil
|
|
341
|
+
label.blank? ? klz.public_send(keys.first).to_s : label
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# The revision must belong to the klass the caller named: the klass row is what
|
|
345
|
+
# authorize_klass! (and the active-revision guard) runs on, so accepting a mismatched
|
|
346
|
+
# pair would let ownership of one template authorize deleting — even the active —
|
|
347
|
+
# revisions of another.
|
|
348
|
+
def revision_of_klass?(revision, klass, klass_name)
|
|
349
|
+
foreign_key = "#{klass_name.sub('Klass', '_klass').downcase}_id"
|
|
350
|
+
!klass.nil? && revision.public_send(foreign_key) == klass.id
|
|
351
|
+
end
|
|
352
|
+
|
|
82
353
|
def delete_klass_revision(params)
|
|
83
354
|
revision = "Labimotion::#{params[:klass]}esRevision".constantize.find(params[:id])
|
|
84
355
|
klass = "Labimotion::#{params[:klass]}".constantize.find_by(id: params[:klass_id]) unless revision.nil?
|
|
85
356
|
error!('Revision is invalid.', 404) if revision.nil?
|
|
357
|
+
error!('Revision is invalid.', 404) unless revision_of_klass?(revision, klass, params[:klass])
|
|
358
|
+
authorize_klass!(klass, :destroy)
|
|
86
359
|
error!('Can not delete the active revision.', 405) if revision.uuid == klass.uuid
|
|
87
|
-
revision
|
|
360
|
+
revision.destroy!
|
|
88
361
|
rescue StandardError => e
|
|
89
362
|
Labimotion.log_exception(e, current_user)
|
|
90
363
|
raise e
|
|
@@ -110,7 +383,6 @@ module Labimotion
|
|
|
110
383
|
raise e
|
|
111
384
|
end
|
|
112
385
|
|
|
113
|
-
|
|
114
386
|
###############
|
|
115
387
|
def generate_klass_file
|
|
116
388
|
klass_names_file = Labimotion::KLASSES_JSON # Rails.root.join('app/packs/klasses.json')
|
|
@@ -35,6 +35,74 @@ module Labimotion
|
|
|
35
35
|
optional :properties_template, type: Hash, desc: 'Element Klass properties template'
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
params :create_ai_dataset_klass_params do
|
|
39
|
+
requires :ols_term_id, type: String, desc: 'CHMO ontology term (e.g. "CHMO:0000470 | mass spectrometry (MS)")'
|
|
40
|
+
optional :desc, type: String, desc: 'Description / intent of the new dataset template'
|
|
41
|
+
optional :cols, type: Integer, values: 1..6, default: 1, desc: 'Default columns per row for generated layers'
|
|
42
|
+
optional :references, type: Array[String], desc: 'Reference links (publications / standards)'
|
|
43
|
+
optional :files, type: Array, desc: 'Reference files for context' do
|
|
44
|
+
optional :filename, type: String, desc: 'File name'
|
|
45
|
+
optional :content, type: String, desc: 'Client-read plaintext (legacy, truncated)'
|
|
46
|
+
optional :content_base64, type: String, desc: 'Base64-encoded raw file bytes (PDF/Excel/text — preferred)'
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
params :create_ai_element_klass_params do
|
|
51
|
+
requires :name, type: String, desc: 'Element Klass Name'
|
|
52
|
+
requires :label, type: String, desc: 'Element Klass Label'
|
|
53
|
+
requires :klass_prefix, type: String, desc: 'Element Klass Short Label Prefix'
|
|
54
|
+
optional :icon_name, type: String, desc: 'Element Klass Icon Name'
|
|
55
|
+
optional :desc, type: String, desc: 'Description / intent of the new element template'
|
|
56
|
+
optional :cols, type: Integer, values: 1..6, default: 1, desc: 'Default columns per row for generated layers'
|
|
57
|
+
optional :references, type: Array[String], desc: 'Reference links (publications / standards)'
|
|
58
|
+
optional :files, type: Array, desc: 'Reference files for context' do
|
|
59
|
+
optional :filename, type: String, desc: 'File name'
|
|
60
|
+
optional :content, type: String, desc: 'Client-read plaintext (legacy, truncated)'
|
|
61
|
+
optional :content_base64, type: String, desc: 'Base64-encoded raw file bytes (PDF/Excel/text — preferred)'
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# AI auto-fill of a generic element instance's DATA VALUES from a document.
|
|
66
|
+
# `files` mirrors the create_ai_*_klass_params file block (source == 'upload').
|
|
67
|
+
params :ai_fill_element_data_params do
|
|
68
|
+
requires :element_id, type: Integer, desc: 'Generic element id whose data values to fill'
|
|
69
|
+
requires :source, type: String, values: %w[attachment analysis upload], desc: 'Value source'
|
|
70
|
+
optional :attachment_id, type: Integer, desc: 'Attachment id (required when source == attachment)'
|
|
71
|
+
optional :container_id, type: Integer, desc: 'Analysis container id (required when source == analysis)'
|
|
72
|
+
optional :files, type: Array, desc: 'Uploaded files (source == upload)' do
|
|
73
|
+
optional :filename, type: String, desc: 'File name'
|
|
74
|
+
optional :content, type: String, desc: 'Client-read plaintext (legacy, truncated)'
|
|
75
|
+
optional :content_base64, type: String, desc: 'Base64-encoded raw file bytes (PDF/Excel/text — preferred)'
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
params :create_ai_segment_klass_params do
|
|
80
|
+
requires :label, type: String, desc: 'Segment Klass Label'
|
|
81
|
+
requires :element_klass, type: Integer, desc: 'Parent Element Klass Id'
|
|
82
|
+
optional :metadata, type: Hash, desc: 'Klass metadata (segment render kind, e.g. { is_properties })'
|
|
83
|
+
optional :desc, type: String, desc: 'Description / intent of the new segment template'
|
|
84
|
+
optional :cols, type: Integer, values: 1..6, default: 1, desc: 'Default columns per row for generated layers'
|
|
85
|
+
optional :references, type: Array[String], desc: 'Reference links (publications / standards)'
|
|
86
|
+
optional :files, type: Array, desc: 'Reference files for context' do
|
|
87
|
+
optional :filename, type: String, desc: 'File name'
|
|
88
|
+
optional :content, type: String, desc: 'Client-read plaintext (legacy, truncated)'
|
|
89
|
+
optional :content_base64, type: String, desc: 'Base64-encoded raw file bytes (PDF/Excel/text — preferred)'
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
params :refine_ai_dataset_klass_params do
|
|
94
|
+
requires :instruction, type: String, desc: 'Natural-language change to apply to the template'
|
|
95
|
+
optional :label, type: String, desc: 'Current template label'
|
|
96
|
+
optional :ols_term_id, type: String, desc: 'CHMO ontology term (context only)'
|
|
97
|
+
optional :cols, type: Integer, values: 1..6, default: 1, desc: 'Fallback columns per row when a layer omits its own'
|
|
98
|
+
optional :layers, type: Hash, desc: 'Current template layers'
|
|
99
|
+
optional :select_options, type: Hash, desc: 'Current template select options'
|
|
100
|
+
optional :history, type: Array, desc: 'Prior chat turns for continuity' do
|
|
101
|
+
optional :role, type: String, desc: 'user | assistant'
|
|
102
|
+
optional :content, type: String, desc: 'Message content'
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
38
106
|
params :update_element_klass_params do
|
|
39
107
|
requires :id, type: Integer, desc: 'Element Klass ID'
|
|
40
108
|
optional :label, type: String, desc: 'Element Klass Label'
|