labimotion 2.4.0.rc5 → 2.4.0.rc6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3e6b4cffa98971ea0ef93e54a537710c7fdc587f2433f038a54f42be1ac63f21
4
- data.tar.gz: b57ba99244c3b9ec975fef1acb2c1b588b8d9bee5a5d6c356d336bc247c08738
3
+ metadata.gz: fa32d7e24804c4f270efdbce75bccf847ae3d30ae1f6b265c17b9c196e9c12a6
4
+ data.tar.gz: b6901471f70ac6874bf68a517ea9d994a3e4054d089a2bc66e042de38e97f4fd
5
5
  SHA512:
6
- metadata.gz: c727c0b2409e463b0e6130986ebdcc528cf6473696b2124889061557f365ec163cc1393d08662b258801ddadafd16f7134e82786d759121b915e47b499814b55
7
- data.tar.gz: 84ac6b69a8326012b61eae9aefef2f248c85ac22fa45cf266cf03114378a0cde2d1839f0798d3d460d35223000283db2a7ee4dcb3bdcfa4e4faac414ad01460b
6
+ metadata.gz: b6612883678081acfa046ea9956424f06b512f5ea941cac2dd219e60809f0ed308026d9e0e8c9a112e067b8718f4a2b55939211b0843100d6329a9502341ddee
7
+ data.tar.gz: c52a79f87e8dffb30d340be92e107e6518c96e3c28d46a6857e6355ff3e30ec399a989cce94553188b4baff83ac548d34d80083b185a523be8c31823bdc7e97c
@@ -7,6 +7,7 @@ module Labimotion
7
7
 
8
8
  helpers Labimotion::GenericHelpers
9
9
  helpers Labimotion::DatasetHelpers
10
+ helpers Labimotion::ParamHelpers
10
11
 
11
12
  resource :generic_dataset do
12
13
  namespace :klasses do
@@ -78,6 +79,34 @@ module Labimotion
78
79
  end
79
80
  end
80
81
 
82
+ namespace :create_ai_klass do
83
+ desc 'create a Generic Dataset Klass from an AI-generated template'
84
+ params do
85
+ use :create_ai_dataset_klass_params
86
+ end
87
+ post do
88
+ msg = create_ai_dataset_klass(params, current_user)
89
+ klass = Labimotion::DatasetKlassEntity.represent(Labimotion::DatasetKlass.all)
90
+ { status: msg[:status], message: msg[:message], klass: klass }
91
+ rescue StandardError => e
92
+ Labimotion.log_exception(e, current_user)
93
+ { error: e.message }
94
+ end
95
+ end
96
+
97
+ namespace :refine_ai_klass do
98
+ desc 'refine a Generic Dataset Klass template with AI (returns the revised template; not persisted)'
99
+ params do
100
+ use :refine_ai_dataset_klass_params
101
+ end
102
+ post do
103
+ refine_ai_dataset_klass(params, current_user)
104
+ rescue StandardError => e
105
+ Labimotion.log_exception(e, current_user)
106
+ { status: 'error', message: e.message }
107
+ end
108
+ end
109
+
81
110
  namespace :find_template do
82
111
  desc 'Find best matching template for given OLS term ID'
83
112
  params do
@@ -212,6 +212,43 @@ module Labimotion
212
212
  end
213
213
  end
214
214
 
215
+ namespace :create_ai_klass do
216
+ desc 'create a Generic Element Klass from an AI-generated template'
217
+ params do
218
+ use :create_ai_element_klass_params
219
+ end
220
+ post do
221
+ authenticate_admin!('elements')
222
+ msg = create_ai_element_klass(params, current_user)
223
+ { status: msg[:status], message: msg[:message],
224
+ klass: Labimotion::ElementKlassEntity.represent(Labimotion::ElementKlass.where(is_active: true)) }
225
+ rescue StandardError => e
226
+ Labimotion.log_exception(e, current_user)
227
+ { error: e.message }
228
+ end
229
+ end
230
+
231
+ # Sibling namespace of :create_ai_klass — MUST stay above the route_param :id
232
+ # blocks below, otherwise POST /ai_fill_data falls through to the element
233
+ # instance route and Element.find(nil) is raised.
234
+ namespace :ai_fill_data do
235
+ desc "Auto-fill a generic element's data values from a document using AI"
236
+ params do
237
+ use :ai_fill_element_data_params
238
+ end
239
+ post do
240
+ msg = ai_fill_element_data(params, current_user)
241
+ if msg[:status] == 'success'
242
+ { status: 'success', values: msg[:values], summary: msg[:summary] }
243
+ else
244
+ { status: 'error', message: msg[:message] }
245
+ end
246
+ rescue StandardError => e
247
+ Labimotion.log_exception(e, current_user)
248
+ { status: 'error', message: e.message }
249
+ end
250
+ end
251
+
215
252
  namespace :update_element_klass do
216
253
  desc 'update Generic Element Klass'
217
254
  params do
@@ -59,6 +59,25 @@ module Labimotion
59
59
  end
60
60
  end
61
61
 
62
+ namespace :create_ai_klass do
63
+ desc 'create a Generic Segment Klass from an AI-generated template'
64
+ params do
65
+ use :create_ai_segment_klass_params
66
+ end
67
+ after_validation do
68
+ authenticate_admin!('segments')
69
+ @klass = fetch_klass('ElementKlass', params[:element_klass])
70
+ end
71
+ post do
72
+ msg = create_ai_segment_klass(current_user, params)
73
+ { status: msg[:status], message: msg[:message],
74
+ klass: Labimotion::SegmentKlassEntity.represent(Labimotion::SegmentKlass.all) }
75
+ rescue StandardError => e
76
+ Labimotion.log_exception(e, current_user)
77
+ { error: e.message }
78
+ end
79
+ end
80
+
62
81
  namespace :update_segment_klass do
63
82
  desc 'update Generic Segment Klass'
64
83
  params do
@@ -58,6 +58,104 @@ module Labimotion
58
58
  raise e
59
59
  end
60
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
+ ds = Labimotion::DatasetKlass.create!(attributes)
117
+ ds.create_klasses_revision(current_user)
118
+ { status: 'success',
119
+ message: "The AI dataset template [#{label}] has been created as inactive. Review and activate it in the designer." }
120
+ rescue StandardError => e
121
+ Labimotion.log_exception(e, current_user)
122
+ { status: 'error', message: e.message }
123
+ end
124
+
125
+ # Refine an existing dataset template with AI and return the revised template
126
+ # (label, layers, select_options) plus a one-line summary of the change.
127
+ # Nothing is persisted — the admin applies the result into the designer's
128
+ # working copy and saves it there (human-in-the-loop, mirrors the create flow).
129
+ def refine_ai_dataset_klass(params, current_user)
130
+ instruction = params[:instruction].to_s.strip
131
+ raise 'An instruction is required' if instruction.blank?
132
+
133
+ current = {
134
+ 'label' => params[:label],
135
+ 'layers' => params[:layers] || {},
136
+ 'select_options' => params[:select_options] || {}
137
+ }
138
+ overrides = ai_user_overrides(current_user)
139
+ ai = Labimotion::AiTemplate.refine(
140
+ current: current,
141
+ instruction: instruction,
142
+ ols_term_id: ai_term_with_label(params[:ols_term_id]),
143
+ history: params[:history],
144
+ cols: params[:cols],
145
+ **overrides
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
+ }
154
+ rescue StandardError => e
155
+ Labimotion.log_exception(e, current_user)
156
+ { status: 'error', message: e.message }
157
+ end
158
+
61
159
  def find_best_match_template(ols_term_id)
62
160
  result = Labimotion::TemplateMatcher.find_best_match(ols_term_id)
63
161
  if result[:template]
@@ -69,6 +167,21 @@ module Labimotion
69
167
 
70
168
  private
71
169
 
170
+ # Pair the CHMO id with its human label ("CHMO:0000470 | mass spectrometry")
171
+ # by resolving the label from ols_terms, so the AI refine scope guard can name
172
+ # the method rather than show a bare id. Falls back to the raw value when the
173
+ # term is missing/unknown; never raises (a failed lookup must not block refine).
174
+ def ai_term_with_label(raw)
175
+ id = raw.to_s.split('|').first.to_s.strip
176
+ return raw.to_s if id.blank? || !defined?(OlsTerm)
177
+
178
+ label = OlsTerm.find_by(term_id: id)&.label
179
+ label.present? ? "#{id} | #{label}" : raw.to_s
180
+ rescue StandardError => e
181
+ Labimotion.log_exception(e)
182
+ raw.to_s
183
+ end
184
+
72
185
  def build_template_response(template, match_type, info_messages)
73
186
  response = {
74
187
  error: '',
@@ -54,6 +54,184 @@ module Labimotion
54
54
  raise e
55
55
  end
56
56
 
57
+ # Create a new (inactive) element klass whose properties template is
58
+ # generated by an LLM from a user-provided name/label plus optional
59
+ # description, reference links and uploaded files. The admin reviews/edits
60
+ # the generated template in the designer and activates it (human-in-the-loop).
61
+ def create_ai_element_klass(params, current_user)
62
+ name = params[:name]
63
+ label = params[:label]
64
+ if Labimotion::ElementKlass.find_by(name: name).present?
65
+ return { status: 'error', message: "An element named [#{name}] already exists." }
66
+ end
67
+
68
+ if Array(params[:files]).size > Labimotion::AiTemplate::MAX_FILES
69
+ return { status: 'error', message: "Too many files (max #{Labimotion::AiTemplate::MAX_FILES})." }
70
+ end
71
+
72
+ overrides = ai_user_overrides(current_user)
73
+ ai = Labimotion::AiTemplate.generate(
74
+ kind: 'element',
75
+ subject: "#{params[:label]} (#{params[:name]})",
76
+ desc: params[:desc],
77
+ cols: params[:cols],
78
+ references: params[:references],
79
+ files: params[:files],
80
+ **overrides
81
+ )
82
+
83
+ uuid = SecureRandom.uuid
84
+ # property-base schema requires pkg, uuid, klass, layers, version, identifier.
85
+ properties_template = {
86
+ 'uuid' => uuid,
87
+ 'klass' => 'ElementKlass',
88
+ 'pkg' => Labimotion::Utils.pkg(nil),
89
+ 'version' => '1.0.0',
90
+ 'identifier' => uuid,
91
+ 'layers' => ai['layers'],
92
+ 'select_options' => ai['select_options'],
93
+ 'metadata' => ai['metadata']
94
+ }
95
+ attributes = {
96
+ 'name' => name,
97
+ 'label' => label,
98
+ 'klass_prefix' => params[:klass_prefix],
99
+ 'icon_name' => params[:icon_name],
100
+ 'desc' => params[:desc].presence || ai['label'].presence,
101
+ 'is_active' => false,
102
+ 'uuid' => uuid,
103
+ 'released_at' => DateTime.now,
104
+ 'properties_template' => properties_template,
105
+ 'properties_release' => properties_template,
106
+ 'created_by' => current_user.id
107
+ }
108
+
109
+ new_klass = Labimotion::ElementKlass.create!(attributes)
110
+ new_klass.reload
111
+ new_klass.create_klasses_revision(current_user)
112
+ klass_names_file = Labimotion::KLASSES_JSON # Rails.root.join('app/packs/klasses.json')
113
+ klasses = Labimotion::ElementKlass.where(is_active: true)&.pluck(:name) || []
114
+ File.write(klass_names_file, klasses)
115
+ { status: 'success',
116
+ message: "The AI element template [#{label}] has been created as inactive. Review and activate it in the designer." }
117
+ rescue StandardError => e
118
+ Labimotion.log_exception(e, current_user)
119
+ { status: 'error', message: e.message }
120
+ end
121
+
122
+ # AI auto-fill of a generic element instance's DATA VALUES from a document
123
+ # (an existing attachment, an analysis, or freshly uploaded files). Reads the
124
+ # source text, asks the LLM to extract values for the element's OWN template
125
+ # fields, validates them, and returns them for the client to merge into the
126
+ # working copy for human review. Nothing is persisted here.
127
+ #
128
+ # Authorization: the caller must be able to READ the element, and the source
129
+ # must BELONG to that element (see the ownership checks below) — this endpoint
130
+ # reads files by id, so it must never read an Attachment/Container the user
131
+ # does not already have access to through this element.
132
+ def ai_fill_element_data(params, current_user)
133
+ element = Labimotion::Element.find(params[:element_id])
134
+ error!('401 Unauthorized', 401) unless ElementPolicy.new(current_user, element).read?
135
+
136
+ text = ai_fill_source_text(element, params)
137
+ overrides = ai_user_overrides(current_user)
138
+ ai = Labimotion::AiTemplate.fill(
139
+ properties: element.properties,
140
+ context_text: text,
141
+ **overrides
142
+ )
143
+ { status: 'success', values: ai['values'], summary: ai['summary'] }
144
+ rescue StandardError => e
145
+ Labimotion.log_exception(e, current_user)
146
+ { status: 'error', message: e.message }
147
+ end
148
+
149
+ # Resolve the requested source into plain text, enforcing ownership first.
150
+ def ai_fill_source_text(element, params)
151
+ case params[:source]
152
+ when 'attachment'
153
+ ai_fill_attachment_text(element, params[:attachment_id])
154
+ when 'analysis'
155
+ ai_fill_analysis_text(element, params[:container_id])
156
+ when 'upload'
157
+ ai_fill_upload_text(params[:files])
158
+ else
159
+ error!('400 Bad Request', 400)
160
+ end
161
+ end
162
+
163
+ # The attachment must be one of the element's own attachments OR an attachment
164
+ # of one of the element's analysis DATASET containers. Only then is it read.
165
+ def ai_fill_attachment_text(element, attachment_id)
166
+ error!('404 Not Found', 404) if attachment_id.nil?
167
+ error!('404 Not Found', 404) unless ai_fill_element_attachment_ids(element).include?(attachment_id.to_i)
168
+
169
+ att = Attachment.find(attachment_id)
170
+ Labimotion::FileExtractor.extract_bytes(att.filename, att.read_file)
171
+ end
172
+
173
+ # The container id must be one of the element's own analysis containers. Then
174
+ # concatenate the extracted text of every attachment on that analysis's dataset
175
+ # children, each prefixed "### <filename>", with the analysis name/description
176
+ # prepended as context. Cap at AiTemplate::MAX_FILES; skip unreadable files.
177
+ def ai_fill_analysis_text(element, container_id)
178
+ error!('404 Not Found', 404) if container_id.nil?
179
+ analysis = element.analyses.detect { |c| c.id == container_id.to_i }
180
+ error!('404 Not Found', 404) if analysis.nil?
181
+
182
+ parts = []
183
+ context = [analysis.name.presence, analysis.description.presence].compact.join(' — ')
184
+ parts << "Analysis: #{context}" if context.present?
185
+
186
+ ai_fill_analysis_attachments(analysis).first(Labimotion::AiTemplate::MAX_FILES).each do |att|
187
+ content = ai_fill_extract_attachment(att)
188
+ next if content.to_s.strip.empty?
189
+
190
+ parts << "### #{att.filename}\n#{content}"
191
+ end
192
+ parts.join("\n\n")
193
+ end
194
+
195
+ def ai_fill_upload_text(files)
196
+ parts = Array(files).first(Labimotion::AiTemplate::MAX_FILES).map do |file|
197
+ name = file[:filename] || file['filename']
198
+ next if name.blank?
199
+
200
+ content = Labimotion::FileExtractor.extract(name, file[:content_base64] || file['content_base64'])
201
+ next if content.to_s.strip.empty?
202
+
203
+ "### #{name}\n#{content}"
204
+ end.compact
205
+ parts.join("\n\n")
206
+ end
207
+
208
+ # Ids of attachments this element may expose to the AI fill: its own
209
+ # attachments plus the attachments of its analysis dataset containers.
210
+ def ai_fill_element_attachment_ids(element)
211
+ own = element.attachments.pluck(:id)
212
+ dataset_ids = ai_fill_dataset_containers(element).map(&:id)
213
+ dataset_att = dataset_ids.empty? ? [] : Attachment.where(attachable_type: 'Container', attachable_id: dataset_ids).pluck(:id)
214
+ (own + dataset_att).uniq
215
+ end
216
+
217
+ # Dataset containers under all of the element's analysis containers.
218
+ def ai_fill_dataset_containers(element)
219
+ element.analyses.flat_map { |analysis| analysis.children.where(container_type: 'dataset').to_a }
220
+ end
221
+
222
+ def ai_fill_analysis_attachments(analysis)
223
+ analysis.children.where(container_type: 'dataset').flat_map do |dataset|
224
+ Attachment.where(attachable_type: 'Container', attachable_id: dataset.id).to_a
225
+ end
226
+ end
227
+
228
+ def ai_fill_extract_attachment(att)
229
+ Labimotion::FileExtractor.extract_bytes(att.filename, att.read_file)
230
+ rescue StandardError => e
231
+ Labimotion.log_exception(e)
232
+ nil
233
+ end
234
+
57
235
  def update_element_klass(current_user, params)
58
236
  place = params[:place] || 100
59
237
  begin
@@ -109,6 +109,68 @@ module Labimotion
109
109
  raise e
110
110
  end
111
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
+
112
174
  def deactivate_klass(params)
113
175
  klz = fetch_klass(params[:klass], params[:id])
114
176
  authorize_klass!(klz, :deactivate)
@@ -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'
@@ -54,6 +54,68 @@ module Labimotion
54
54
  raise e
55
55
  end
56
56
 
57
+ # Create a new (inactive) segment klass whose properties template is
58
+ # generated by an LLM from a user-provided label plus optional description,
59
+ # reference links and uploaded files. The parent ElementKlass must be set on
60
+ # @klass by the API (after_validation), mirroring create_segment_klass. The
61
+ # admin reviews/edits the generated template in the designer and activates it.
62
+ def create_ai_segment_klass(current_user, params)
63
+ return { status: 'error', message: 'A parent element class is required.' } if @klass.nil?
64
+
65
+ if Array(params[:files]).size > Labimotion::AiTemplate::MAX_FILES
66
+ return { status: 'error', message: "Too many files (max #{Labimotion::AiTemplate::MAX_FILES})." }
67
+ end
68
+
69
+ overrides = ai_user_overrides(current_user)
70
+ ai = Labimotion::AiTemplate.generate(
71
+ kind: 'segment',
72
+ subject: params[:label],
73
+ desc: params[:desc],
74
+ cols: params[:cols],
75
+ references: params[:references],
76
+ files: params[:files],
77
+ **overrides
78
+ )
79
+
80
+ uuid = SecureRandom.uuid
81
+ # property-base schema requires pkg, uuid, klass, layers, version, identifier.
82
+ properties_template = {
83
+ 'uuid' => uuid,
84
+ 'klass' => 'SegmentKlass',
85
+ 'pkg' => Labimotion::Utils.pkg(nil),
86
+ 'version' => '1.0.0',
87
+ 'identifier' => uuid,
88
+ 'layers' => ai['layers'],
89
+ 'select_options' => ai['select_options'],
90
+ 'metadata' => ai['metadata']
91
+ }
92
+ attributes = {
93
+ 'label' => params[:label],
94
+ 'desc' => params[:desc].presence || ai['label'].presence,
95
+ 'element_klass' => @klass,
96
+ 'place' => 100,
97
+ 'is_active' => false,
98
+ 'uuid' => uuid,
99
+ 'released_at' => DateTime.now,
100
+ 'properties_template' => properties_template,
101
+ 'properties_release' => properties_template,
102
+ 'created_by' => current_user.id
103
+ }
104
+ # Klass-level segment metadata (render kind: own tab vs the host element's
105
+ # Properties tab), mirroring the non-AI create_segment_klass which persists
106
+ # params[:metadata] via declared. Absent -> leave the column default.
107
+ attributes['metadata'] = params[:metadata] if params[:metadata].present?
108
+
109
+ klass = Labimotion::SegmentKlass.create!(attributes)
110
+ klass.reload
111
+ klass.create_klasses_revision(current_user)
112
+ { status: 'success',
113
+ message: "The AI segment template [#{params[:label]}] has been created as inactive. Review and activate it in the designer." }
114
+ rescue StandardError => e
115
+ Labimotion.log_exception(e, current_user)
116
+ { status: 'error', message: e.message }
117
+ end
118
+
57
119
  def update_segment_klass(current_user, params)
58
120
  segment = fetch_klass('SegmentKlass', params[:id])
59
121
  authorize_klass!(segment, :write)