labimotion 2.3.0 → 2.4.0.rc11

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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -1
  3. data/lib/labimotion/apis/generic_dataset_api.rb +93 -3
  4. data/lib/labimotion/apis/generic_element_api.rb +198 -8
  5. data/lib/labimotion/apis/generic_klass_api.rb +74 -8
  6. data/lib/labimotion/apis/klass_share_api.rb +648 -0
  7. data/lib/labimotion/apis/labimotion_ai_api.rb +252 -0
  8. data/lib/labimotion/apis/labimotion_api.rb +4 -0
  9. data/lib/labimotion/apis/labimotion_doi_api.rb +24 -10
  10. data/lib/labimotion/apis/labimotion_template_browse_api.rb +13 -1
  11. data/lib/labimotion/apis/ontology_root_api.rb +74 -0
  12. data/lib/labimotion/apis/segment_api.rb +77 -10
  13. data/lib/labimotion/apis/user_klass_settings_api.rb +93 -0
  14. data/lib/labimotion/conf.rb +5 -0
  15. data/lib/labimotion/constants.rb +64 -0
  16. data/lib/labimotion/entities/application_entity.rb +8 -0
  17. data/lib/labimotion/entities/eln_element_entity.rb +6 -0
  18. data/lib/labimotion/entities/generic_klass_entity.rb +126 -0
  19. data/lib/labimotion/entities/klass_share_entity.rb +48 -0
  20. data/lib/labimotion/entities/properties_entity.rb +78 -2
  21. data/lib/labimotion/entities/segment_entity.rb +8 -0
  22. data/lib/labimotion/entities/user_klass_setting_entity.rb +10 -0
  23. data/lib/labimotion/helpers/cover_image_helpers.rb +181 -0
  24. data/lib/labimotion/helpers/dataset_helpers.rb +187 -1
  25. data/lib/labimotion/helpers/element_helpers.rb +282 -6
  26. data/lib/labimotion/helpers/exporter_helpers.rb +17 -2
  27. data/lib/labimotion/helpers/generic_helpers.rb +293 -4
  28. data/lib/labimotion/helpers/param_helpers.rb +105 -0
  29. data/lib/labimotion/helpers/sample_association_helpers.rb +7 -0
  30. data/lib/labimotion/helpers/segment_helpers.rb +102 -4
  31. data/lib/labimotion/libs/ai_egress_guard.rb +99 -0
  32. data/lib/labimotion/libs/ai_klass_queue.rb +88 -0
  33. data/lib/labimotion/libs/ai_klass_validator.rb +74 -0
  34. data/lib/labimotion/libs/ai_models.rb +201 -0
  35. data/lib/labimotion/libs/ai_template.rb +2285 -0
  36. data/lib/labimotion/libs/converter.rb +5 -43
  37. data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
  38. data/lib/labimotion/libs/export_element.rb +128 -13
  39. data/lib/labimotion/libs/file_extractor.rb +210 -0
  40. data/lib/labimotion/libs/linked_element.rb +313 -0
  41. data/lib/labimotion/libs/ontology_store.rb +226 -0
  42. data/lib/labimotion/libs/ontology_terms.rb +227 -0
  43. data/lib/labimotion/libs/owner_resolver.rb +50 -0
  44. data/lib/labimotion/libs/ownership_audit.rb +73 -0
  45. data/lib/labimotion/libs/sample_association.rb +52 -1
  46. data/lib/labimotion/libs/share_notifier.rb +114 -0
  47. data/lib/labimotion/libs/share_resolver.rb +373 -0
  48. data/lib/labimotion/libs/user_ai_settings.rb +129 -0
  49. data/lib/labimotion/models/cellline.rb +47 -0
  50. data/lib/labimotion/models/concerns/datasetable.rb +3 -0
  51. data/lib/labimotion/models/concerns/matrice_labimotion.rb +124 -0
  52. data/lib/labimotion/models/concerns/segmentable.rb +2 -0
  53. data/lib/labimotion/models/concerns/template_doi.rb +133 -0
  54. data/lib/labimotion/models/dataset_klass.rb +1 -1
  55. data/lib/labimotion/models/element_klass.rb +1 -1
  56. data/lib/labimotion/models/klass_share.rb +129 -0
  57. data/lib/labimotion/models/segment_klass.rb +1 -1
  58. data/lib/labimotion/models/user_klass_setting.rb +58 -0
  59. data/lib/labimotion/models/user_setting.rb +60 -0
  60. data/lib/labimotion/usecases/build_template_doi_xml.rb +69 -23
  61. data/lib/labimotion/usecases/release_template_doi.rb +42 -17
  62. data/lib/labimotion/usecases/template_doi_helpers.rb +28 -6
  63. data/lib/labimotion/usecases/update_template_publication_metadata.rb +71 -2
  64. data/lib/labimotion/utils/export_utils.rb +1 -0
  65. data/lib/labimotion/utils/import_utils.rb +20 -3
  66. data/lib/labimotion/utils/serializer.rb +27 -0
  67. data/lib/labimotion/utils/units.rb +32 -67
  68. data/lib/labimotion/version.rb +1 -1
  69. data/lib/labimotion.rb +27 -0
  70. metadata +45 -3
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'grape'
4
+ require 'labimotion/constants'
5
+
6
+ module Labimotion
7
+ ## Element cover images (chem-generic-ui#1050, labimotion#259).
8
+ ## `cover_images` is the one element-metadata key whose shape the gem owns:
9
+ ## validation and normalization of the stored references, their direct
10
+ ## save, the resolution of a reference to the attachment holding its
11
+ ## picture, and the bytes served for the display area (originals), the
12
+ ## picker rows (thumbnails) and the docx export.
13
+ module CoverImageHelpers
14
+ extend Grape::API::Helpers
15
+
16
+ # Element metadata is a client-round-tripped document with several writers —
17
+ # layer groups/restrictions from the element details page, host regComps
18
+ # through the registry seam — and those writers remove a key by omitting it.
19
+ # So a present :metadata param replaces the stored hash wholesale; only an
20
+ # absent param leaves the stored metadata untouched (an old client that does
21
+ # not send :metadata must never wipe a saved cover selection). `cover_images`
22
+ # is the one key the gem owns: its entries are validated against the picker's
23
+ # cap and source vocabulary and normalized to exactly source/id, so nothing
24
+ # else reaches the jsonb column through that key.
25
+ def prepare_element_metadata(stored, incoming)
26
+ return stored || {} if incoming.nil?
27
+
28
+ # to_h returns self for a plain Hash — dup so the caller's copy stays intact.
29
+ metadata = incoming.to_h.dup
30
+ return metadata unless metadata.key?('cover_images') || metadata.key?(:cover_images)
31
+
32
+ entries = metadata.key?('cover_images') ? metadata['cover_images'] : metadata[:cover_images]
33
+ metadata.delete(:cover_images)
34
+ metadata['cover_images'] = validate_cover_images!(entries)
35
+ metadata
36
+ end
37
+
38
+ def validate_cover_images!(entries)
39
+ cover_images_error!('must be an array') unless entries.is_a?(Array)
40
+ max = Constants::CoverImage::MAX
41
+ cover_images_error!("holds at most #{max} entries") if entries.size > max
42
+
43
+ entries.map { |entry| normalized_cover_image!(entry) }
44
+ end
45
+
46
+ def normalized_cover_image!(entry)
47
+ cover_image_entry_error! unless entry.is_a?(Hash)
48
+ source = entry['source'] || entry[:source]
49
+ id = entry['id'] || entry[:id]
50
+ cover_image_entry_error! unless Constants::CoverImage::SOURCES.include?(source) && id.is_a?(Integer)
51
+ { 'source' => source, 'id' => id }
52
+ end
53
+
54
+ def cover_image_entry_error!
55
+ cover_images_error!("entries must be { source: #{Constants::CoverImage::SOURCES.join(' | ')}, id: integer }")
56
+ end
57
+
58
+ def cover_images_error!(reason)
59
+ error!("metadata.cover_images #{reason}", 422)
60
+ end
61
+
62
+ # Direct write of the one gem-owned metadata key (the cover picker's
63
+ # Save): a targeted merge, deliberately narrower than the element save's
64
+ # wholesale-replace contract — this writer owns exactly cover_images and
65
+ # can neither wipe nor resurrect any other key. Live like klass settings:
66
+ # not versioned until the next element save snapshots metadata.
67
+ def save_cover_images(element, entries)
68
+ validated = validate_cover_images!(entries)
69
+ element.update!(metadata: (element.metadata || {}).merge('cover_images' => validated))
70
+ validated
71
+ end
72
+
73
+ # Resolves a stored cover-image reference to the Attachment holding its
74
+ # picture, or nil. Ownership is part of the resolution: an attachment is
75
+ # looked up through the element's own association, an analysis must be one
76
+ # of the element's analyses — a foreign id answers nil, never a file.
77
+ def cover_image_attachment(element, source, sid)
78
+ case source
79
+ when 'attachment'
80
+ element.attachments.find_by(id: sid)
81
+ when 'analysis'
82
+ container = element.analyses.find_by(id: sid)
83
+ container && analysis_preview_attachment(container)
84
+ end
85
+ end
86
+
87
+ # Server twin of the ELN client's getContainerImageData: among the
88
+ # thumbnail-bearing attachments of the analysis' dataset children, the
89
+ # container's preferred_thumbnail wins while it still exists, then the
90
+ # newest "combined" image, then the newest overall.
91
+ def analysis_preview_attachment(container)
92
+ dataset_ids = container.children.where(container_type: 'dataset').pluck(:id)
93
+ return nil if dataset_ids.empty?
94
+
95
+ candidates = Attachment.where(attachable_type: 'Container', attachable_id: dataset_ids, thumb: true).to_a
96
+ pick_preview_attachment(container, candidates)
97
+ end
98
+
99
+ def pick_preview_attachment(container, candidates)
100
+ return nil if candidates.empty?
101
+
102
+ preferred = preferred_candidate(container, candidates)
103
+ return preferred if preferred
104
+
105
+ combined = candidates.select { |att| att.filename.to_s.downcase.include?('combined') }
106
+ (combined.empty? ? candidates : combined).max_by(&:updated_at)
107
+ end
108
+
109
+ # hstore values are strings — the stored preferred_thumbnail id arrives as '1'.
110
+ def preferred_candidate(container, candidates)
111
+ preferred_id = (container.extended_metadata || {})['preferred_thumbnail'].to_i
112
+ candidates.find { |att| att.id == preferred_id }
113
+ end
114
+
115
+ # The picker's rows ask for variant=thumbnail — the small uniform PNG;
116
+ # everything else means the original (display area). An svg's STORED
117
+ # thumbnail is black (see cover_rasterize), so it is re-rasterized from
118
+ # the original instead, falling back to the stored one.
119
+ def cover_image_body(att, variant)
120
+ return cover_image_payload(att) unless variant == 'thumbnail'
121
+
122
+ thumb = svg_cover_thumbnail(att) || att.read_thumbnail
123
+ [thumb, 'image/png'] if thumb
124
+ end
125
+
126
+ COVER_THUMB_RESIZE = '256x256>'
127
+
128
+ def svg_cover_thumbnail(att)
129
+ mime = att.content_type.to_s.downcase
130
+ return nil unless mime.include?('svg')
131
+
132
+ data = att.read_file
133
+ data && cover_rasterize(data, mime, resize: COVER_THUMB_RESIZE)
134
+ end
135
+
136
+ ## ImageMagick's own SVG renderer reads the CSS keyword `transparent` as
137
+ ## black — the defect behind the ELN thumbnails' black background — so
138
+ ## paint properties are normalized to `none` first and the result is a
139
+ ## PNG flattened onto white. Nil when conversion is unavailable or
140
+ ## fails; callers fall back.
141
+ SVG_TRANSPARENT_RE =
142
+ /((?:fill|stroke|stop-color|flood-color|background(?:-color)?)\s*[:=]\s*["']?)transparent\b/i
143
+
144
+ def cover_rasterize(data, mime, resize: nil)
145
+ # Deferred on purpose: mini_magick is a host-bundle gem, not a
146
+ # dependency of this gem — at the top of the file it would break gem
147
+ # load (and the spec suite) on a host without it, while here a miss is
148
+ # rescued below into the nil fallback.
149
+ require 'mini_magick'
150
+ data = data.gsub(SVG_TRANSPARENT_RE, '\1none') if mime.include?('svg')
151
+ image = MiniMagick::Image.read(data, cover_read_ext(mime))
152
+ image.format('png') do |convert|
153
+ convert.background('white')
154
+ convert.flatten
155
+ convert.resize(resize) if resize
156
+ end
157
+ image.to_blob
158
+ rescue LoadError, StandardError => e
159
+ Labimotion.log_exception(e)
160
+ nil
161
+ end
162
+
163
+ def cover_read_ext(mime)
164
+ subtype = mime.split('/').last.to_s.sub('+xml', '')
165
+ subtype.empty? ? '.img' : ".#{subtype}"
166
+ end
167
+
168
+ # The bytes + content type to serve for a cover attachment: the original
169
+ # file whenever a browser can render it, otherwise the PNG thumbnail
170
+ # (TIFF originals, raw-data files behind an analysis preview). Nil when
171
+ # neither is readable.
172
+ def cover_image_payload(att)
173
+ if att.type_image? && !att.type_image_tiff?
174
+ data = att.read_file
175
+ return [data, att.content_type] if data
176
+ end
177
+ thumb = att.read_thumbnail
178
+ [thumb, 'image/png'] if thumb
179
+ end
180
+ end
181
+ end
@@ -31,6 +31,9 @@ module Labimotion
31
31
  { status: 'success', message: "This dataset: #{attributes['label']} has the latest version!" }
32
32
  else
33
33
  ds = Labimotion::DatasetKlass.find_by(ols_term_id: attributes['ols_term_id'])
34
+ # Upgrading bumps the version and cuts a revision — :release, owner-only; the gate
35
+ # lives on the update branch because only the ols_term_id lookup decides it.
36
+ authorize_klass!(ds, :release)
34
37
  ds.update!(attributes)
35
38
  ds.create_klasses_revision(current_user)
36
39
  { status: 'success',
@@ -38,7 +41,13 @@ module Labimotion
38
41
  end
39
42
  else
40
43
  attributes['created_by'] = current_user.id
41
- ds = Labimotion::DatasetKlass.create!(attributes)
44
+ # The owner row shares the create's transaction: a klass committed without it is
45
+ # owner-less, which falls open to the legacy designer-wide gate.
46
+ ds = Labimotion::DatasetKlass.transaction do
47
+ Labimotion::DatasetKlass.create!(attributes).tap do |klz|
48
+ Labimotion::KlassShare.seed_owner!(klz, current_user.id)
49
+ end
50
+ end
42
51
  ds.create_klasses_revision(current_user)
43
52
  { status: 'success',
44
53
  message: "The dataset: #{attributes['label']} has been created using version: #{attributes['version']}!" }
@@ -49,6 +58,168 @@ module Labimotion
49
58
  raise e
50
59
  end
51
60
 
61
+ # Create a new (inactive) dataset klass whose properties template is
62
+ # generated by an LLM from a CHMO ontology term plus optional description,
63
+ # reference links and uploaded files. The admin reviews/edits the generated
64
+ # template in the designer and activates it (human-in-the-loop).
65
+ def create_ai_dataset_klass(params, current_user)
66
+ ols_term_id = params[:ols_term_id].to_s.split('|').first.to_s.strip
67
+ raise 'An ontology term (CHMO) is required' if ols_term_id.blank?
68
+
69
+ if Labimotion::DatasetKlass.find_by(ols_term_id: ols_term_id).present?
70
+ return { status: 'error',
71
+ message: "A dataset template already exists for #{ols_term_id}. Edit it in the designer instead." }
72
+ end
73
+
74
+ if Array(params[:files]).size > Labimotion::AiTemplate::MAX_FILES
75
+ return { status: 'error', message: "Too many files (max #{Labimotion::AiTemplate::MAX_FILES})." }
76
+ end
77
+
78
+ overrides = ai_user_overrides(current_user)
79
+ ai = Labimotion::AiTemplate.generate(
80
+ ols_term_id: params[:ols_term_id],
81
+ desc: params[:desc],
82
+ cols: params[:cols],
83
+ references: params[:references],
84
+ files: params[:files],
85
+ **overrides
86
+ )
87
+
88
+ uuid = SecureRandom.uuid
89
+ label = ai['label'].presence ||
90
+ params[:ols_term_id].to_s.split('|').last.to_s.strip.presence ||
91
+ 'AI dataset template'
92
+ # property-base schema requires pkg, uuid, klass, layers, version, identifier.
93
+ properties_template = {
94
+ 'uuid' => uuid,
95
+ 'klass' => 'DatasetKlass',
96
+ 'pkg' => Labimotion::Utils.pkg(nil),
97
+ 'version' => '1.0.0',
98
+ 'identifier' => uuid,
99
+ 'layers' => ai['layers'],
100
+ 'select_options' => ai['select_options'],
101
+ 'metadata' => ai['metadata']
102
+ }
103
+ attributes = {
104
+ 'uuid' => uuid,
105
+ 'label' => label,
106
+ 'desc' => params[:desc].presence || ai['label'].presence,
107
+ 'ols_term_id' => ols_term_id,
108
+ 'place' => ((Labimotion::DatasetKlass.all.length * 10) || 0) + 10,
109
+ 'is_active' => false,
110
+ 'released_at' => DateTime.now,
111
+ 'properties_template' => properties_template,
112
+ 'properties_release' => properties_template,
113
+ 'created_by' => current_user.id
114
+ }
115
+
116
+ # Same as the non-AI create beside it: the owner row shares the create's
117
+ # transaction, because a klass committed without one is owner-less and
118
+ # falls OPEN to the legacy designer-wide gate — every designer of the
119
+ # family could then edit, release and delete it, while its creator holds
120
+ # no special standing at all. seed_owner! rescues only RecordNotUnique and
121
+ # RecordInvalid, so any other failure has to take the klass down with it;
122
+ # this helper turns every StandardError into { status: 'error' }, and
123
+ # without the transaction the caller would be told the create failed while
124
+ # an unowned template silently persisted.
125
+ ds = Labimotion::DatasetKlass.transaction do
126
+ Labimotion::DatasetKlass.create!(attributes).tap do |klz|
127
+ Labimotion::KlassShare.seed_owner!(klz, current_user.id)
128
+ end
129
+ end
130
+ ds.create_klasses_revision(current_user)
131
+ # `record` is the row itself. The background job that calls this needs the
132
+ # template, not a sentence about it, to link the notification back to it.
133
+ { status: 'success', record: ds,
134
+ message: "The AI dataset template [#{label}] has been created as inactive. Review and activate it in the designer." }
135
+ rescue StandardError => e
136
+ Labimotion.log_exception(e, current_user)
137
+ { status: 'error', message: e.message }
138
+ end
139
+
140
+ # Refine an existing dataset template with AI and return the revised template
141
+ # (label, layers, select_options) plus a one-line summary of the change.
142
+ # Nothing is persisted — the admin applies the result into the designer's
143
+ # working copy and saves it there (human-in-the-loop, mirrors the create flow).
144
+ def refine_ai_dataset_klass(params, current_user)
145
+ instruction = params[:instruction].to_s.strip
146
+ raise 'An instruction is required' if instruction.blank?
147
+
148
+ current = {
149
+ 'label' => params[:label],
150
+ 'layers' => params[:layers] || {},
151
+ 'select_options' => params[:select_options] || {}
152
+ }
153
+ overrides = ai_user_overrides(current_user)
154
+ ai = Labimotion::AiTemplate.refine(
155
+ current: current,
156
+ instruction: instruction,
157
+ ols_term_id: ai_term_with_label(params[:ols_term_id]),
158
+ history: params[:history],
159
+ cols: params[:cols],
160
+ **overrides
161
+ )
162
+ refine_outcome(ai, params[:label])
163
+ rescue StandardError => e
164
+ Labimotion.log_exception(e, current_user)
165
+ { status: 'error', message: e.message }
166
+ end
167
+
168
+ # Turn an instruction into a list of STRUCTURAL operations the designer applies
169
+ # itself, from an INDEX of the open template rather than the template. A layout
170
+ # change then costs what the sentence costs, not what the template costs.
171
+ #
172
+ # Four outcomes, three of them normal:
173
+ # 'success' — operations to apply
174
+ # 'design' — not expressible as operations; the client re-asks on refine
175
+ # 'unrelated' — not a template change at all; the turn STOPS here
176
+ # 'error' — the request failed
177
+ # 'design' is deliberately not an error: it is the routing answer for every
178
+ # instruction that needs new fields, wording, units or ontology terms.
179
+ # 'unrelated' exists so an off-topic message does not fall through to refine,
180
+ # where the whole template would be re-emitted to report that nothing changed.
181
+ def plan_ai_dataset_klass(params, current_user)
182
+ instruction = params[:instruction].to_s.strip
183
+ raise 'An instruction is required' if instruction.blank?
184
+
185
+ overrides = ai_user_overrides(current_user)
186
+ ai = Labimotion::AiTemplate.plan(
187
+ index: params[:index] || {},
188
+ instruction: instruction,
189
+ ols_term_id: ai_term_with_label(params[:ols_term_id]),
190
+ history: params[:history],
191
+ **overrides
192
+ )
193
+ plan_outcome(ai)
194
+ rescue StandardError => e
195
+ Labimotion.log_exception(e, current_user)
196
+ { status: 'error', message: e.message }
197
+ end
198
+
199
+ def refine_outcome(refined, fallback_label)
200
+ {
201
+ status: 'success',
202
+ label: refined['label'].presence || fallback_label,
203
+ layers: refined['layers'],
204
+ select_options: refined['select_options'],
205
+ summary: refined['summary'],
206
+ usage: refined['usage'],
207
+ model: refined['model']
208
+ }
209
+ end
210
+
211
+ # Which of the three normal outcomes this plan is. `model` rides on all of
212
+ # them: it is what actually answered, which is not always what the user
213
+ # picked — a keyless user's choice is clamped to the server allowlist.
214
+ def plan_outcome(plan)
215
+ base = { reason: plan['reason'], usage: plan['usage'], model: plan['model'] }
216
+ return base.merge(status: 'unrelated') if plan['unrelated']
217
+ return base.merge(status: 'design') if plan['needs_design']
218
+
219
+ { status: 'success', operations: plan['operations'], summary: plan['summary'],
220
+ usage: plan['usage'], model: plan['model'] }
221
+ end
222
+
52
223
  def find_best_match_template(ols_term_id)
53
224
  result = Labimotion::TemplateMatcher.find_best_match(ols_term_id)
54
225
  if result[:template]
@@ -60,6 +231,21 @@ module Labimotion
60
231
 
61
232
  private
62
233
 
234
+ # Pair the CHMO id with its human label ("CHMO:0000470 | mass spectrometry")
235
+ # by resolving the label from ols_terms, so the AI refine scope guard can name
236
+ # the method rather than show a bare id. Falls back to the raw value when the
237
+ # term is missing/unknown; never raises (a failed lookup must not block refine).
238
+ def ai_term_with_label(raw)
239
+ id = raw.to_s.split('|').first.to_s.strip
240
+ return raw.to_s if id.blank? || !defined?(OlsTerm)
241
+
242
+ label = OlsTerm.find_by(term_id: id)&.label
243
+ label.present? ? "#{id} | #{label}" : raw.to_s
244
+ rescue StandardError => e
245
+ Labimotion.log_exception(e)
246
+ raw.to_s
247
+ end
248
+
63
249
  def build_template_response(template, match_type, info_messages)
64
250
  response = {
65
251
  error: '',