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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -1
- data/lib/labimotion/apis/generic_dataset_api.rb +93 -3
- data/lib/labimotion/apis/generic_element_api.rb +198 -8
- data/lib/labimotion/apis/generic_klass_api.rb +74 -8
- data/lib/labimotion/apis/klass_share_api.rb +648 -0
- data/lib/labimotion/apis/labimotion_ai_api.rb +252 -0
- data/lib/labimotion/apis/labimotion_api.rb +4 -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/ontology_root_api.rb +74 -0
- data/lib/labimotion/apis/segment_api.rb +77 -10
- data/lib/labimotion/apis/user_klass_settings_api.rb +93 -0
- data/lib/labimotion/conf.rb +5 -0
- data/lib/labimotion/constants.rb +64 -0
- data/lib/labimotion/entities/application_entity.rb +8 -0
- data/lib/labimotion/entities/eln_element_entity.rb +6 -0
- data/lib/labimotion/entities/generic_klass_entity.rb +126 -0
- data/lib/labimotion/entities/klass_share_entity.rb +48 -0
- data/lib/labimotion/entities/properties_entity.rb +78 -2
- data/lib/labimotion/entities/segment_entity.rb +8 -0
- data/lib/labimotion/entities/user_klass_setting_entity.rb +10 -0
- data/lib/labimotion/helpers/cover_image_helpers.rb +181 -0
- data/lib/labimotion/helpers/dataset_helpers.rb +187 -1
- data/lib/labimotion/helpers/element_helpers.rb +282 -6
- data/lib/labimotion/helpers/exporter_helpers.rb +17 -2
- data/lib/labimotion/helpers/generic_helpers.rb +293 -4
- data/lib/labimotion/helpers/param_helpers.rb +105 -0
- data/lib/labimotion/helpers/sample_association_helpers.rb +7 -0
- data/lib/labimotion/helpers/segment_helpers.rb +102 -4
- data/lib/labimotion/libs/ai_egress_guard.rb +99 -0
- data/lib/labimotion/libs/ai_klass_queue.rb +88 -0
- data/lib/labimotion/libs/ai_klass_validator.rb +74 -0
- data/lib/labimotion/libs/ai_models.rb +201 -0
- data/lib/labimotion/libs/ai_template.rb +2285 -0
- data/lib/labimotion/libs/converter.rb +5 -43
- data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
- data/lib/labimotion/libs/export_element.rb +128 -13
- data/lib/labimotion/libs/file_extractor.rb +210 -0
- data/lib/labimotion/libs/linked_element.rb +313 -0
- data/lib/labimotion/libs/ontology_store.rb +226 -0
- data/lib/labimotion/libs/ontology_terms.rb +227 -0
- data/lib/labimotion/libs/owner_resolver.rb +50 -0
- data/lib/labimotion/libs/ownership_audit.rb +73 -0
- data/lib/labimotion/libs/sample_association.rb +52 -1
- data/lib/labimotion/libs/share_notifier.rb +114 -0
- data/lib/labimotion/libs/share_resolver.rb +373 -0
- data/lib/labimotion/libs/user_ai_settings.rb +129 -0
- data/lib/labimotion/models/cellline.rb +47 -0
- data/lib/labimotion/models/concerns/datasetable.rb +3 -0
- data/lib/labimotion/models/concerns/matrice_labimotion.rb +124 -0
- data/lib/labimotion/models/concerns/segmentable.rb +2 -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 +129 -0
- data/lib/labimotion/models/segment_klass.rb +1 -1
- data/lib/labimotion/models/user_klass_setting.rb +58 -0
- data/lib/labimotion/models/user_setting.rb +60 -0
- 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/export_utils.rb +1 -0
- data/lib/labimotion/utils/import_utils.rb +20 -3
- data/lib/labimotion/utils/serializer.rb +27 -0
- data/lib/labimotion/utils/units.rb +32 -67
- data/lib/labimotion/version.rb +1 -1
- data/lib/labimotion.rb +27 -0
- 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
|
-
|
|
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: '',
|