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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/lib/labimotion/apis/generic_dataset_api.rb +41 -3
  4. data/lib/labimotion/apis/generic_element_api.rb +60 -8
  5. data/lib/labimotion/apis/generic_klass_api.rb +50 -6
  6. data/lib/labimotion/apis/klass_share_api.rb +646 -0
  7. data/lib/labimotion/apis/labimotion_api.rb +1 -0
  8. data/lib/labimotion/apis/labimotion_doi_api.rb +24 -10
  9. data/lib/labimotion/apis/labimotion_template_browse_api.rb +13 -1
  10. data/lib/labimotion/apis/segment_api.rb +61 -10
  11. data/lib/labimotion/constants.rb +14 -0
  12. data/lib/labimotion/entities/application_entity.rb +13 -1
  13. data/lib/labimotion/entities/generic_klass_entity.rb +125 -0
  14. data/lib/labimotion/entities/klass_share_entity.rb +48 -0
  15. data/lib/labimotion/helpers/dataset_helpers.rb +123 -1
  16. data/lib/labimotion/helpers/element_helpers.rb +206 -4
  17. data/lib/labimotion/helpers/generic_helpers.rb +276 -4
  18. data/lib/labimotion/helpers/param_helpers.rb +68 -0
  19. data/lib/labimotion/helpers/segment_helpers.rb +87 -4
  20. data/lib/labimotion/libs/ai_egress_guard.rb +84 -0
  21. data/lib/labimotion/libs/ai_template.rb +1482 -0
  22. data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
  23. data/lib/labimotion/libs/file_extractor.rb +210 -0
  24. data/lib/labimotion/libs/owner_resolver.rb +50 -0
  25. data/lib/labimotion/libs/ownership_audit.rb +73 -0
  26. data/lib/labimotion/libs/share_notifier.rb +114 -0
  27. data/lib/labimotion/libs/share_resolver.rb +369 -0
  28. data/lib/labimotion/models/concerns/template_doi.rb +133 -0
  29. data/lib/labimotion/models/dataset_klass.rb +1 -1
  30. data/lib/labimotion/models/element_klass.rb +1 -1
  31. data/lib/labimotion/models/klass_share.rb +126 -0
  32. data/lib/labimotion/models/segment_klass.rb +1 -1
  33. data/lib/labimotion/usecases/build_template_doi_xml.rb +69 -23
  34. data/lib/labimotion/usecases/release_template_doi.rb +42 -17
  35. data/lib/labimotion/usecases/template_doi_helpers.rb +28 -6
  36. data/lib/labimotion/usecases/update_template_publication_metadata.rb +71 -2
  37. data/lib/labimotion/utils/prop.rb +1 -0
  38. data/lib/labimotion/version.rb +1 -1
  39. data/lib/labimotion.rb +11 -0
  40. metadata +28 -2
@@ -0,0 +1,1482 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+ require 'labimotion/utils/units'
6
+
7
+ module Labimotion
8
+ # AiTemplate generates a generic dataset template (DatasetKlass properties
9
+ # template) from a natural-language description using an LLM.
10
+ #
11
+ # The client targets the KIT KI-Toolbox (https://ki-toolbox.scc.kit.edu), an
12
+ # OpenAI-compatible chat-completions gateway (Open WebUI), but is fully
13
+ # config-driven (config/labimotion_ai.yml, then ENV) so it can point at any
14
+ # compatible service. Settings resolve yml -> ENV -> default:
15
+ #
16
+ # :api_key / KI_TOOLBOX_API_KEY (required) bearer token (created in the UI)
17
+ # :base_url / KI_TOOLBOX_BASE_URL default https://ki-toolbox.scc.kit.edu
18
+ # :api_path / KI_TOOLBOX_API_PATH default /api/v1/chat/completions
19
+ # :model / KI_TOOLBOX_MODEL default azure.gpt-4.1-mini
20
+ # :max_tokens, :timeout, :system_prompt
21
+ #
22
+ # The LLM is asked to return a JSON document describing the template layers
23
+ # and fields in the LabIMotion format. Only the data part is returned here;
24
+ # wrapping it into a persistable DatasetKlass (uuid, pkg, klass, ...) is the
25
+ # caller's job (see Labimotion::DatasetHelpers#create_ai_dataset_klass).
26
+ class AiTemplate
27
+ DEFAULT_BASE_URL = 'https://ki-toolbox.scc.kit.edu'
28
+ DEFAULT_API_PATH = '/api/v1/chat/completions'
29
+ DEFAULT_MODEL = 'azure.gpt-4.1-mini'
30
+ DEFAULT_MAX_TOKENS = 8000
31
+ # A connection test only needs a valid round-trip, not a useful completion, so
32
+ # cap the reply hard to keep the ping cheap and fast.
33
+ PING_MAX_TOKENS = 8
34
+ REQUEST_TIMEOUT = 120
35
+ MAX_FILES = 10
36
+ # Slightly above FileExtractor::MAX_OUT_CHARS (8000) so its "[truncated]"
37
+ # marker is not chopped off by the slice below.
38
+ MAX_FILE_CHARS = 9000
39
+
40
+ # Field "type" values allowed in generated templates — a practical subset of
41
+ # the LabIMotion field-base schema enum.
42
+ FIELD_TYPES = %w[
43
+ text textarea integer number checkbox date datetime
44
+ select select-multi ontology-select system-defined label upload table
45
+ ].freeze
46
+
47
+ # Field types whose choices live in the shared select_options map.
48
+ SELECT_TYPES = %w[select select-multi].freeze
49
+
50
+ # Field types that may carry a physical unit (mapped to a system-defined group).
51
+ UNIT_CANDIDATE_TYPES = %w[number integer system-defined].freeze
52
+
53
+ # Column types allowed inside a "table" field's sub_fields; others -> text.
54
+ TABLE_COL_TYPES = %w[text number integer checkbox date datetime].freeze
55
+
56
+ # Generate a metadata template for a generic dataset, element or segment.
57
+ #
58
+ # The JSON template schema (layers / fields / select_options) is IDENTICAL
59
+ # across all three kinds — only the prompt wording changes. The dataset path
60
+ # is driven by a CHMO ontology term; the element/segment paths are driven by
61
+ # a subject string (the element/segment name/label).
62
+ #
63
+ # @param kind [String] 'dataset' (default), 'element' or 'segment'
64
+ # @param ols_term_id [String] CHMO term id, e.g. "CHMO:0000470 | mass spectrometry (MS)" (dataset only)
65
+ # @param subject [String] the element/segment name/label (element/segment only)
66
+ # @param desc [String] free-text description of the template
67
+ # @param cols [Integer] default columns per row for generated layers (1-6)
68
+ # @param references [Array<String>] reference links (publications / standards)
69
+ # @param files [Array<Hash>] [{ 'filename' => ..., 'content' => ... }, ...]
70
+ # @return [Hash] { 'label' => String, 'layers' => Hash, 'select_options' => Hash }
71
+ def self.generate(kind: 'dataset', ols_term_id: nil, subject: nil, desc: nil, cols: 1, references: [], files: [], model: nil, api_key: nil, base_url: nil, api_path: nil)
72
+ new(kind: kind, ols_term_id: ols_term_id, subject: subject, desc: desc, cols: cols, references: references,
73
+ files: files, model: model, api_key: api_key, base_url: base_url, api_path: api_path).generate
74
+ end
75
+
76
+ # Refine an existing dataset template through a natural-language instruction.
77
+ #
78
+ # The caller passes the CURRENT template (label, layers, select_options) —
79
+ # which already reflects any previously-applied refinements — plus the latest
80
+ # instruction and an optional short history of prior turns for continuity.
81
+ # The LLM returns the FULL revised template in the same shape, together with
82
+ # a one-line summary of what it changed. Nothing is persisted here; the admin
83
+ # reviews the result in the designer and saves it (human-in-the-loop).
84
+ #
85
+ # @param current [Hash] { 'label' => String, 'layers' => Hash, 'select_options' => Hash }
86
+ # @param instruction [String] the change to apply
87
+ # @param history [Array<Hash>] prior turns [{ 'role' => 'user'|'assistant', 'content' => String }]
88
+ # @param cols [Integer] fallback columns per row when a layer omits its own (1-6)
89
+ # @param model [String, nil] per-user model override (falls back to yml/ENV/default)
90
+ # @param api_key [String, nil] per-user API key override (falls back to yml/ENV)
91
+ # @param base_url [String, nil] per-user provider base URL (honored only with api_key)
92
+ # @param api_path [String, nil] per-user provider chat-completions path (honored only with api_key)
93
+ # @return [Hash] { 'label' => String, 'layers' => Hash, 'select_options' => Hash, 'summary' => String }
94
+ def self.refine(current:, instruction:, ols_term_id: nil, history: [], cols: 1, model: nil, api_key: nil, base_url: nil, api_path: nil)
95
+ new(ols_term_id: ols_term_id, cols: cols, history: history, model: model, api_key: api_key, base_url: base_url, api_path: api_path)
96
+ .refine(current: current, instruction: instruction)
97
+ end
98
+
99
+ # Extract DATA VALUES for an existing generic element/segment/dataset instance
100
+ # from a document's text, to pre-fill the working copy for human review. This
101
+ # does NOT design or modify a template — it only reads values for the fields the
102
+ # given `properties` template already defines. Nothing is persisted here.
103
+ #
104
+ # @param properties [Hash] the element instance properties ({ 'layers' => ..., 'select_options' => ... })
105
+ # @param context_text [String] plain text extracted from the source document(s)
106
+ # @param model [String, nil] per-user model override (falls back to yml/ENV/default)
107
+ # @param api_key [String, nil] per-user API key override (falls back to yml/ENV)
108
+ # @param base_url [String, nil] per-user provider base URL (honored only with api_key)
109
+ # @param api_path [String, nil] per-user provider chat-completions path (honored only with api_key)
110
+ # @return [Hash] { 'values' => { layer_key => { field_key => value } }, 'summary' => String }
111
+ def self.fill(properties:, context_text:, model: nil, api_key: nil, base_url: nil, api_path: nil)
112
+ new(ols_term_id: '', model: model, api_key: api_key, base_url: base_url, api_path: api_path)
113
+ .fill(properties: properties, context_text: context_text)
114
+ end
115
+
116
+ # Verify the (per-user or server) AI settings by sending one tiny chat request
117
+ # and reporting whether it round-trips. Nothing is generated or persisted. Same
118
+ # resolution as the real calls: base_url/api_path are honored only ALONGSIDE a
119
+ # personal key (custom_endpoint?), and a custom base_url is SSRF-validated.
120
+ #
121
+ # @return [Hash] { 'ok' => true, 'model' => .., 'endpoint' => .., 'ms' => .. }
122
+ # @raise [RuntimeError] with a user-facing message on any failure
123
+ def self.ping(model: nil, api_key: nil, base_url: nil, api_path: nil)
124
+ new(ols_term_id: '', model: model, api_key: api_key, base_url: base_url, api_path: api_path).ping
125
+ end
126
+
127
+ # Reverse index of physical units -> the LabIMotion system-defined unit group,
128
+ # built once from Labimotion::Units::FIELDS. Maps a normalized unit token (from
129
+ # a unit's label OR its key) to { group:, value_system: }. Tokens used by more
130
+ # than one group are AMBIGUOUS and dropped, so a lookup never guesses the wrong
131
+ # group (e.g. "Pa" -> pressure vs elastic modulus falls back to a plain number).
132
+ def self.unit_index
133
+ @unit_index ||= build_unit_index
134
+ end
135
+
136
+ # All valid unit-group keys (the option_layers value of a system-defined field).
137
+ def self.unit_groups
138
+ @unit_groups ||= Labimotion::Units::FIELDS.map { |g| g[:field].to_s }.freeze
139
+ end
140
+
141
+ def self.build_unit_index
142
+ collect_unit_tokens.each_with_object({}) do |(token, groups), index|
143
+ next unless groups.size == 1
144
+
145
+ gkey, ukey = groups.first
146
+ index[token] = { group: gkey, value_system: ukey }
147
+ end
148
+ end
149
+
150
+ # token -> { group => first_unit_key }. A token seen under >1 group signals it
151
+ # is ambiguous (kept here, filtered out in build_unit_index).
152
+ def self.collect_unit_tokens
153
+ tokens = {}
154
+ Labimotion::Units::FIELDS.each do |group|
155
+ gkey = group[:field].to_s
156
+ Array(group[:units]).each { |unit| record_unit_tokens(tokens, gkey, unit) }
157
+ end
158
+ tokens
159
+ end
160
+
161
+ def self.record_unit_tokens(tokens, gkey, unit)
162
+ ukey = unit[:key].to_s
163
+ return if ukey.empty?
164
+
165
+ [unit[:label], ukey].each do |raw|
166
+ token = normalize_unit_token(raw)
167
+ next if token.empty?
168
+
169
+ (tokens[token] ||= {})[gkey] ||= ukey
170
+ end
171
+ end
172
+
173
+ # Fold a unit string to a comparable token: strip HTML (<sup>2</sup> -> 2), map
174
+ # unicode superscripts to digits, micro sign to "u", downcase, drop spaces. So
175
+ # "°C", "µmol/L", "g/cm<sup>3</sup>" match the model's "°c"/"umol/l"/"g/cm3".
176
+ def self.normalize_unit_token(str)
177
+ s = str.to_s.gsub(/<[^>]+>/, '')
178
+ s = s.tr('²³¹⁰⁴⁵⁶⁷⁸⁹', '2310456789')
179
+ s = s.gsub(/[µμ]/, 'u').downcase
180
+ s.gsub(/\s+/, '')
181
+ end
182
+
183
+ def initialize(kind: 'dataset', ols_term_id: nil, subject: nil, desc: nil, cols: 1, references: [], files: [], history: [], model: nil, api_key: nil, base_url: nil, api_path: nil)
184
+ @kind = kind.to_s
185
+ @subject = subject.to_s.strip
186
+ @ols_term_id = ols_term_id.to_s.strip
187
+ @desc = desc.to_s.strip
188
+ @cols = cols.to_i.clamp(1, 6)
189
+ @references = Array(references).map(&:to_s).reject(&:blank?)
190
+ @files = Array(files)
191
+ @history = Array(history)
192
+ # Per-user overrides (from the caller); blank -> fall back to yml/ENV/default.
193
+ @model_override = model.to_s.strip
194
+ @api_key_override = api_key.to_s.strip
195
+ # A user may only redirect to their OWN provider when they also bring their
196
+ # own key — the shared server key must never be sent to a user-supplied URL.
197
+ @base_url_override = base_url.to_s.strip
198
+ @api_path_override = api_path.to_s.strip
199
+ end
200
+
201
+ def generate
202
+ raise 'AI API key is not configured (set config/labimotion_ai.yml :api_key or KI_TOOLBOX_API_KEY)' if api_key.blank?
203
+ if @kind == 'dataset'
204
+ raise 'An ontology term (CHMO) is required' if @ols_term_id.blank?
205
+ else
206
+ raise 'A name/label is required' if @subject.blank?
207
+ end
208
+
209
+ response = post_messages
210
+ raise "AI request failed (HTTP #{response.code})" unless response.code == 200
211
+
212
+ body = JSON.parse(response.body)
213
+ guard_finish_reason!(body)
214
+
215
+ text = extract_text(body)
216
+ raise 'AI returned an empty response' if text.blank?
217
+
218
+ normalize(parse_json(text))
219
+ rescue JSON::ParserError => e
220
+ log_ai_response('generate could not parse response', response&.body)
221
+ Labimotion.log_exception(e)
222
+ raise 'AI returned a response that could not be parsed as a template'
223
+ end
224
+
225
+ def refine(current:, instruction:)
226
+ raise 'AI API key is not configured (set config/labimotion_ai.yml :api_key or KI_TOOLBOX_API_KEY)' if api_key.blank?
227
+ raise 'An instruction is required' if instruction.to_s.strip.blank?
228
+
229
+ response = post_chat(refine_messages(current, instruction))
230
+ raise "AI request failed (HTTP #{response.code})" unless response.code == 200
231
+
232
+ body = JSON.parse(response.body)
233
+ guard_finish_reason!(body)
234
+
235
+ text = extract_text(body)
236
+ raise 'AI returned an empty response' if text.blank?
237
+
238
+ data = parse_json(text)
239
+ result = normalize_preserving(data)
240
+ # Guard against a malformed response silently wiping the template — a
241
+ # dataset template always has at least one layer.
242
+ if result['layers'].empty?
243
+ log_ai_response('refine produced no layers', text)
244
+ raise 'AI returned an empty template (the model response contained no layers). ' \
245
+ 'Try a more capable model.'
246
+ end
247
+
248
+ result['summary'] = (data.is_a?(Hash) ? data['summary'].to_s.strip : '')
249
+ result
250
+ rescue JSON::ParserError => e
251
+ log_ai_response('refine could not parse response', response&.body)
252
+ Labimotion.log_exception(e)
253
+ raise 'AI returned a response that could not be parsed as a template'
254
+ end
255
+
256
+ def fill(properties:, context_text:)
257
+ raise 'AI API key is not configured (set config/labimotion_ai.yml :api_key or KI_TOOLBOX_API_KEY)' if api_key.blank?
258
+ raise 'No readable text could be extracted from the selected source' if context_text.to_s.strip.blank?
259
+
260
+ props = properties.is_a?(Hash) ? properties : {}
261
+ schema = fill_field_schema(props)
262
+ raise 'This element template has no fields to fill' if schema.empty?
263
+
264
+ response = post_chat(fill_messages(schema, context_text))
265
+ raise "AI request failed (HTTP #{response.code})" unless response.code == 200
266
+
267
+ body = JSON.parse(response.body)
268
+ guard_finish_reason!(body)
269
+
270
+ text = extract_text(body)
271
+ raise 'AI returned an empty response' if text.blank?
272
+
273
+ data = parse_json(text)
274
+ raw_values = data.is_a?(Hash) ? data['values'] : nil
275
+ {
276
+ 'values' => normalize_fill_values(props, raw_values),
277
+ 'summary' => (data.is_a?(Hash) ? data['summary'].to_s.strip : '')
278
+ }
279
+ rescue JSON::ParserError => e
280
+ log_ai_response('fill could not parse response', response&.body)
281
+ Labimotion.log_exception(e)
282
+ raise 'AI returned a response that could not be parsed'
283
+ end
284
+
285
+ def ping
286
+ raise 'AI API key is not configured' if api_key.blank?
287
+
288
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
289
+ response = post_chat([{ role: 'user', content: 'ping' }], PING_MAX_TOKENS)
290
+ ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
291
+ raise ping_error(response.code) unless response.code == 200
292
+
293
+ { 'ok' => true, 'model' => model, 'endpoint' => "#{base_url}#{api_path}", 'ms' => ms }
294
+ end
295
+
296
+ private
297
+
298
+ # Raise a clear error for terminal finish_reasons BEFORE we try to parse: a
299
+ # content-filter block, or a response truncated at the token limit — the latter
300
+ # would otherwise surface as a confusing parse/empty-template error.
301
+ def guard_finish_reason!(body)
302
+ case Array(body['choices']).first&.dig('finish_reason')
303
+ when 'content_filter'
304
+ raise 'AI request was declined by the content filter'
305
+ when 'length'
306
+ raise 'AI response was cut off at the token limit — increase :max_tokens in ' \
307
+ 'config/labimotion_ai.yml or choose a model with a larger output limit.'
308
+ end
309
+ end
310
+
311
+ # Best-effort: record the raw model output (truncated) to log/labimotion.log so a
312
+ # response that can't be turned into a template can be diagnosed. Never raises.
313
+ def log_ai_response(context, raw)
314
+ return if raw.to_s.strip.empty?
315
+
316
+ Labimotion.logger.warn("AiTemplate #{context}; raw (truncated): #{raw.to_s[0, 2000]}")
317
+ rescue StandardError
318
+ nil
319
+ end
320
+
321
+ # Map a non-200 status from the ping into a message that points at the setting
322
+ # most likely at fault. Network/SSRF failures raise before this (from post_chat).
323
+ def ping_error(code)
324
+ case code.to_i
325
+ when 401, 403 then 'Authentication failed — check the API key.'
326
+ when 404 then 'Endpoint not found — check the Provider URL and path.'
327
+ when 400, 422 then 'Provider rejected the request — check the model id.'
328
+ when 300..399 then 'Endpoint redirected — not allowed.'
329
+ else "AI request failed (HTTP #{code})."
330
+ end
331
+ end
332
+
333
+ # config/labimotion_ai.yml (Rails.configuration.labimotion_ai) for the
334
+ # current environment, or {} when the app/initializer is unavailable.
335
+ def cfg
336
+ @cfg ||= begin
337
+ loaded = (Rails.configuration.labimotion_ai if defined?(Rails) && Rails.respond_to?(:configuration))
338
+ loaded || {}
339
+ rescue StandardError
340
+ {}
341
+ end
342
+ end
343
+
344
+ # Resolve a setting: yml value first, then ENV var, then the built-in default.
345
+ def setting(key, env_key, default = nil)
346
+ cfg[key].presence || ENV[env_key].presence || default
347
+ end
348
+
349
+ def api_key
350
+ @api_key ||= (@api_key_override.presence || setting(:api_key, 'KI_TOOLBOX_API_KEY', nil))
351
+ end
352
+
353
+ # True when the caller brought BOTH a personal key and a provider URL: only
354
+ # then is the user-supplied endpoint honored (the server key is never sent to
355
+ # a user URL). A custom endpoint is SSRF-validated before use.
356
+ def custom_endpoint?
357
+ @api_key_override.present? && @base_url_override.present?
358
+ end
359
+
360
+ def base_url
361
+ @base_url ||=
362
+ if custom_endpoint?
363
+ Labimotion::AiEgressGuard.validate!(@base_url_override)
364
+ else
365
+ setting(:base_url, 'KI_TOOLBOX_BASE_URL', DEFAULT_BASE_URL)
366
+ end.to_s.chomp('/')
367
+ end
368
+
369
+ def api_path
370
+ return @api_path_override if custom_endpoint? && @api_path_override.present?
371
+
372
+ setting(:api_path, 'KI_TOOLBOX_API_PATH', DEFAULT_API_PATH)
373
+ end
374
+
375
+ def model
376
+ @model_override.presence || setting(:model, 'KI_TOOLBOX_MODEL', DEFAULT_MODEL)
377
+ end
378
+
379
+ def max_tokens
380
+ setting(:max_tokens, 'KI_TOOLBOX_MAX_TOKENS', DEFAULT_MAX_TOKENS).to_i
381
+ end
382
+
383
+ def request_timeout
384
+ setting(:timeout, 'KI_TOOLBOX_TIMEOUT', REQUEST_TIMEOUT).to_i
385
+ end
386
+
387
+ def post_messages
388
+ post_chat(
389
+ [
390
+ { role: 'system', content: [system_prompt, field_rules_prompt].join("\n\n") },
391
+ { role: 'user', content: user_prompt }
392
+ ]
393
+ )
394
+ end
395
+
396
+ # POST an OpenAI-compatible chat-completions request for the given messages.
397
+ # tokens defaults to the full max_tokens; the connection ping passes a small
398
+ # cap so a test round-trip stays cheap.
399
+ def post_chat(messages, tokens = max_tokens)
400
+ HTTParty.post(
401
+ "#{base_url}#{api_path}",
402
+ headers: {
403
+ 'Authorization' => "Bearer #{api_key}",
404
+ 'Content-Type' => 'application/json'
405
+ },
406
+ body: {
407
+ model: model,
408
+ max_tokens: tokens,
409
+ messages: messages
410
+ }.to_json,
411
+ timeout: request_timeout,
412
+ # Never follow redirects: a public URL that 302s to an internal address
413
+ # is the classic SSRF bypass of the egress guard.
414
+ follow_redirects: false
415
+ )
416
+ end
417
+
418
+ # OpenAI-compatible chat completions return the text at
419
+ # choices[0].message.content.
420
+ def extract_text(body)
421
+ choice = Array(body['choices']).first || {}
422
+ (choice['message'] || {})['content'].to_s.strip
423
+ end
424
+
425
+ # The system prompt comes from config/labimotion_ai.yml (kind-specific key);
426
+ # the default_*_system_prompt is the built-in fallback for each kind.
427
+ def system_prompt
428
+ case @kind
429
+ when 'element'
430
+ cfg[:element_system_prompt].presence || default_element_system_prompt
431
+ when 'segment'
432
+ cfg[:segment_system_prompt].presence || default_segment_system_prompt
433
+ else
434
+ cfg[:system_prompt].presence || default_system_prompt
435
+ end
436
+ end
437
+
438
+ def default_system_prompt
439
+ <<~PROMPT
440
+ You are an assistant that designs metadata templates for analytical
441
+ chemistry datasets in the Chemotion ELN (LabIMotion generic dataset
442
+ framework).
443
+
444
+ Given an analytical method (a CHMO ontology term) and optional context,
445
+ design a concise, practical dataset template that a chemist would use to
446
+ record the metadata and results of that kind of measurement.
447
+
448
+ Respond with a SINGLE JSON object only — no prose, no markdown, no code
449
+ fences. The JSON must have exactly this shape:
450
+
451
+ {
452
+ "label": "<short human-readable template name>",
453
+ "layers": {
454
+ "<layer_key>": {
455
+ "key": "<layer_key>",
456
+ "label": "<Layer label>",
457
+ "cols": 1,
458
+ "color": "default",
459
+ "position": 1,
460
+ "group": "<optional UI section name>",
461
+ "fields": [
462
+ {
463
+ "field": "<field_key>",
464
+ "type": "text",
465
+ "label": "<Field label>",
466
+ "position": 1,
467
+ "required": false
468
+ }
469
+ ]
470
+ }
471
+ },
472
+ "select_options": {}
473
+ }
474
+
475
+ Rules (LabIMotion property schema):
476
+ - layer_key and field_key are lowercase snake_case, unique within their
477
+ scope, and contain only [a-z0-9_]. A field "label" is human-readable and
478
+ keeps standard acronyms (IUPAC, SMILES, m/z).
479
+ - Each layer MUST include: key, label, cols (integer, usually 1),
480
+ color ("default"), position (1-based integer) and fields (array).
481
+ - Each field MUST include: field, type, label, position, required.
482
+ - "type" MUST be one of: "text", "textarea", "integer", "number",
483
+ "checkbox", "date", "datetime", "select", "select-multi",
484
+ "ontology-select", "system-defined", "upload", "table".
485
+ - For a "select"/"select-multi" field, list the choices INLINE on the field
486
+ as "options": [ { "key": "k", "label": "Label" } ] (a plain string array
487
+ also works); do not set option_layers or a separate select_options block.
488
+ Design guidance — recognise the method and build the layers IT needs:
489
+ - Organise fields into layers that follow the workflow of the technique,
490
+ drawing from: an "overview" layer (operator, measurement date, and a
491
+ "method" field of type "ontology-select" whose default is the given
492
+ ontology term); an instrument/setup layer; an acquisition/conditions layer
493
+ (parameters that define how the measurement is run); a sample layer; a
494
+ processing layer; and a results layer. Include ONLY the layers that fit
495
+ this method and add method-specific ones. Aim for practical coverage
496
+ (about 3-8 layers), not exhaustiveness.
497
+ - Do NOT create a "general" or "General description" layer — the system adds
498
+ that (title, description). Put administrative fields in "overview".
499
+ - Optionally set a layer "group" (a short section name, e.g. "Measurement",
500
+ "Sample & data") to bundle related layers into one UI section; leave
501
+ "overview" ungrouped. The system builds the section index from these tags.
502
+ - Keep it method-specific; do not duplicate fields already implied by the
503
+ analysis container (e.g. the analysis kind/type itself).
504
+ PROMPT
505
+ end
506
+
507
+ def default_element_system_prompt
508
+ generic_system_prompt('element')
509
+ end
510
+
511
+ def default_segment_system_prompt
512
+ generic_system_prompt('segment')
513
+ end
514
+
515
+ # System prompt for the element/segment kinds. The JSON-shape rules are
516
+ # identical to the dataset prompt — only the noun ("element"/"segment") and
517
+ # the framing sentence differ.
518
+ def generic_system_prompt(noun)
519
+ <<~PROMPT
520
+ You are an assistant that designs metadata templates for a LabIMotion
521
+ generic #{noun} in the Chemotion ELN (LabIMotion generic element
522
+ framework).
523
+
524
+ Given a #{noun} name/label and optional context, design a concise,
525
+ practical metadata template that a scientist would use to record the
526
+ structured metadata for that kind of #{noun}.
527
+
528
+ Respond with a SINGLE JSON object only — no prose, no markdown, no code
529
+ fences. The JSON must have exactly this shape:
530
+
531
+ {
532
+ "label": "<short human-readable template name>",
533
+ "layers": {
534
+ "<layer_key>": {
535
+ "key": "<layer_key>",
536
+ "label": "<Layer label>",
537
+ "cols": 1,
538
+ "color": "default",
539
+ "position": 1,
540
+ "group": "<optional UI section name>",
541
+ "fields": [
542
+ {
543
+ "field": "<field_key>",
544
+ "type": "text",
545
+ "label": "<Field label>",
546
+ "position": 1,
547
+ "required": false
548
+ }
549
+ ]
550
+ }
551
+ },
552
+ "select_options": {}
553
+ }
554
+
555
+ Rules (LabIMotion property schema):
556
+ - layer_key and field_key are lowercase snake_case, unique within their
557
+ scope, and contain only [a-z0-9_]. A field "label" is human-readable and
558
+ keeps standard acronyms (IUPAC, SMILES, m/z).
559
+ - Each layer MUST include: key, label, cols (integer, usually 1),
560
+ color ("default"), position (1-based integer) and fields (array).
561
+ - Each field MUST include: field, type, label, position, required.
562
+ - "type" MUST be one of: "text", "textarea", "integer", "number",
563
+ "checkbox", "date", "datetime", "select", "select-multi",
564
+ "ontology-select", "system-defined", "upload", "table".
565
+ - For a "select"/"select-multi" field, list the choices INLINE on the field
566
+ as "options": [ { "key": "k", "label": "Label" } ] (a plain string array
567
+ also works); do not set option_layers or a separate select_options block.
568
+ Design guidance — recognise the #{noun} and build the layers IT needs:
569
+ - Organise fields into layers by theme, drawing from: an "overview" layer
570
+ (identifiers, source/origin, responsible person, date); one or more
571
+ property/attribute layers describing the #{noun}; measurement or result
572
+ layers; and a notes layer. Include ONLY the layers that fit and add
573
+ #{noun}-specific ones. Aim for practical coverage (about 3-8 layers), not
574
+ exhaustiveness.
575
+ - Do NOT create a "general" or "General description" layer — the system adds
576
+ that. Put administrative fields in "overview".
577
+ - Optionally set a layer "group" (a short section name) to bundle related
578
+ layers into one UI section; leave "overview" ungrouped. The system builds
579
+ the section index from these tags.
580
+ - Keep it specific to the #{noun}; do not duplicate fields already implied by
581
+ the container.
582
+ PROMPT
583
+ end
584
+
585
+ # Extra field-type rules appended to EVERY system prompt (create and refine,
586
+ # all kinds), independent of the customizable system prompt so they always
587
+ # reach the model. Two rules the models otherwise get wrong: numbers that
588
+ # carry a unit must be "system-defined" (with a unit group), and fixed-choice
589
+ # values must be "select"/"select-multi" (with matching select_options). The
590
+ # unit-group keys are the ONLY valid option_layers for system-defined fields
591
+ # (a curated subset of Labimotion::Units::FIELDS).
592
+ def field_rules_prompt
593
+ <<~PROMPT
594
+ Additional field-type rules — apply these when choosing each field's "type":
595
+
596
+ 1) A NUMBER THAT HAS A PHYSICAL UNIT (temperature, mass, volume, amount,
597
+ concentration, pressure, time, length, energy, voltage, current, frequency,
598
+ speed, percentage, etc.) must carry its unit. Use type "number" and add a
599
+ "unit" field with the unit symbol, e.g.:
600
+ { "field": "temp", "type": "number", "label": "Temperature", "unit": "°C" }
601
+ More unit examples: "mg", "mL", "µmol/L", "bar", "kJ/mol", "Hz", "%", "V".
602
+ The system maps the unit to the correct unit group automatically.
603
+ If the unit is NOT a standard physical unit (e.g. m/z, cm⁻¹, lines/mm,
604
+ a.u.) or the number has no unit, use "number" with NO "unit" and put any
605
+ unit in the LABEL instead, e.g. "Spectral resolution (cm⁻¹)".
606
+
607
+ 2) A VALUE CHOSEN FROM A FIXED SET OF OPTIONS must use "select" (one choice)
608
+ or "select-multi" (several at once) — never a free-text "text" field. List
609
+ the choices INLINE on the field:
610
+ "options": [ { "key": "k1", "label": "Label 1" },
611
+ { "key": "k2", "label": "Label 2" } ]
612
+ A plain array of strings ("options": ["Label 1", "Label 2"]) also works.
613
+ Do NOT build a separate "select_options" block or set "option_layers" —
614
+ the system creates those from your inline options.
615
+
616
+ 3) For RAW MEASUREMENT DATA, use type "upload" for a data-file field and type
617
+ "table" for a results table. Give a table its columns INLINE as:
618
+ "columns": [ { "name": "m/z", "type": "number" },
619
+ { "name": "intensity", "type": "number" } ]
620
+ (a plain array of strings also works; column "type" is one of text,
621
+ number, integer, checkbox, date, datetime — default text). The system
622
+ builds the table structure from your columns.
623
+
624
+ Note: never set "option_layers", "value_system", "select_options" or
625
+ "sub_fields" yourself — the system derives them from your "unit" (numbers),
626
+ "options" (choices) and "columns" (tables).
627
+ PROMPT
628
+ end
629
+
630
+ def user_prompt
631
+ case @kind
632
+ when 'element', 'segment'
633
+ generic_user_prompt
634
+ else
635
+ dataset_user_prompt
636
+ end
637
+ end
638
+
639
+ def dataset_user_prompt
640
+ parts = []
641
+ parts << "Analytical method (CHMO ontology term): #{@ols_term_id}"
642
+ parts << "Template description / intent: #{@desc}" if @desc.present?
643
+ parts << "Default layout: set each layer's \"cols\" to #{@cols} (fields per row)."
644
+ if @references.any?
645
+ parts << 'Reference links (publications / standards such as DCAT, MICHI):'
646
+ @references.each { |ref| parts << "- #{ref}" }
647
+ end
648
+ file_context = files_context
649
+ parts << file_context if file_context.present?
650
+ parts << 'Design the dataset template now and return only the JSON object.'
651
+ parts.join("\n")
652
+ end
653
+
654
+ def generic_user_prompt
655
+ parts = []
656
+ parts << "#{@kind.capitalize} name/label: #{@subject}"
657
+ parts << "Template description / intent: #{@desc}" if @desc.present?
658
+ parts << "Default layout: set each layer's \"cols\" to #{@cols} (fields per row)."
659
+ if @references.any?
660
+ parts << 'Reference links (publications / standards):'
661
+ @references.each { |ref| parts << "- #{ref}" }
662
+ end
663
+ file_context = files_context
664
+ parts << file_context if file_context.present?
665
+ parts << "Design the #{@kind} template now and return only the JSON object."
666
+ parts.join("\n")
667
+ end
668
+
669
+ def files_context
670
+ usable = @files.first(MAX_FILES).map do |file|
671
+ name = file['filename'] || file[:filename]
672
+ next if name.blank?
673
+
674
+ # Prefer server-side extraction of the raw bytes (PDF / Excel / text);
675
+ # fall back to legacy client-read plaintext. FileExtractor never raises.
676
+ b64 = file['content_base64'] || file[:content_base64]
677
+ content = b64.present? ? Labimotion::FileExtractor.extract(name, b64) : (file['content'] || file[:content])
678
+ snippet = content.to_s[0, MAX_FILE_CHARS]
679
+ snippet.present? ? "### #{name}\n#{snippet}" : "### #{name} (no readable text)"
680
+ end.compact
681
+ return '' if usable.empty?
682
+
683
+ "Attached reference files (truncated):\n#{usable.join("\n\n")}"
684
+ end
685
+
686
+ # Build the chat messages for a refine request: a schema-aware system prompt,
687
+ # the current template as JSON, any prior conversation turns (for continuity)
688
+ # and the latest change request.
689
+ def refine_messages(current, instruction)
690
+ messages = [{ role: 'system', content: refine_system_content(current) }]
691
+ messages << { role: 'user', content: current_template_context(current) }
692
+ @history.each do |turn|
693
+ role = (turn['role'] || turn[:role]).to_s
694
+ content = (turn['content'] || turn[:content]).to_s
695
+ next if content.strip.blank?
696
+
697
+ messages << { role: (role == 'assistant' ? 'assistant' : 'user'), content: content }
698
+ end
699
+ messages << { role: 'user', content: refine_instruction_prompt(instruction) }
700
+ messages
701
+ end
702
+
703
+ def current_template_context(current)
704
+ current = {} unless current.is_a?(Hash)
705
+ layers = annotate_layer_groups(current['layers'] || current[:layers] || {}, current_metadata(current))
706
+ template = {
707
+ 'label' => current['label'] || current[:label],
708
+ 'layers' => layers,
709
+ 'select_options' => current['select_options'] || current[:select_options] || {}
710
+ }
711
+ "Current dataset template (JSON):\n#{JSON.generate(template)}"
712
+ end
713
+
714
+ def current_metadata(current)
715
+ current['metadata'] || current[:metadata]
716
+ end
717
+
718
+ # Re-attach an existing metadata.groups block onto the layers as a "group" tag
719
+ # (the shape the model emits) so a refine round-trips grouping without the model
720
+ # having to reproduce the metadata block itself.
721
+ def annotate_layer_groups(layers, metadata)
722
+ return layers unless layers.is_a?(Hash)
723
+
724
+ groups = metadata_groups(metadata)
725
+ return layers if groups.empty?
726
+
727
+ apply_group_tags(layers, layer_group_map(groups))
728
+ end
729
+
730
+ def metadata_groups(metadata)
731
+ return [] unless metadata.is_a?(Hash)
732
+
733
+ groups = metadata['groups'] || metadata[:groups]
734
+ groups.is_a?(Array) ? groups : []
735
+ end
736
+
737
+ # { layer_key => group_label } from a metadata.groups array.
738
+ def layer_group_map(groups)
739
+ map = {}
740
+ groups.each do |grp|
741
+ next unless grp.is_a?(Hash)
742
+
743
+ label = (grp['label'] || grp[:label]).to_s
744
+ next if label.empty?
745
+
746
+ Array(grp['layers'] || grp[:layers]).each { |lk| map[lk.to_s] = label }
747
+ end
748
+ map
749
+ end
750
+
751
+ def apply_group_tags(layers, layer_group)
752
+ layers.each_with_object({}) do |(key, layer), out|
753
+ copy = layer.is_a?(Hash) ? layer.dup : layer
754
+ grp = layer_group[key.to_s]
755
+ copy['group'] = grp if grp.present? && copy.is_a?(Hash)
756
+ out[key] = copy
757
+ end
758
+ end
759
+
760
+ def refine_instruction_prompt(instruction)
761
+ <<~PROMPT.strip
762
+ Change request: #{instruction}
763
+
764
+ Apply this change to the current template. Preserve everything the request
765
+ does not mention (labels, fields, options, units and other properties).
766
+ Return the COMPLETE updated template as a single JSON object with keys
767
+ "label", "layers", "select_options" and a short "summary" (one sentence
768
+ describing what you changed). No prose, no markdown, no code fences.
769
+ PROMPT
770
+ end
771
+
772
+ def refine_system_prompt
773
+ cfg[:refine_system_prompt].presence || default_refine_system_prompt
774
+ end
775
+
776
+ def default_refine_system_prompt
777
+ <<~PROMPT
778
+ You are an assistant that edits metadata templates for analytical
779
+ chemistry datasets in the Chemotion ELN (LabIMotion generic dataset
780
+ framework).
781
+
782
+ You are given the current template as JSON and a change request. Apply the
783
+ requested change and return the COMPLETE updated template. Keep everything
784
+ the request does not mention exactly as it was, including any extra field
785
+ properties (units, sub-fields, restrictions, defaults).
786
+
787
+ Respond with a SINGLE JSON object only — no prose, no markdown, no code
788
+ fences — with exactly these keys: "label", "layers", "select_options" and
789
+ "summary" (one sentence describing what you changed). "layers" and
790
+ "select_options" keep the same shape as the input.
791
+
792
+ Rules (LabIMotion property schema):
793
+ - layer_key and field_key are lowercase snake_case ([a-z0-9_]), unique
794
+ within their scope.
795
+ - Each layer MUST include: key, label, cols (integer 1-6), color
796
+ ("default"), position (1-based integer) and fields (array).
797
+ - Each field MUST include: field, type, label, position, required.
798
+ - "type" MUST be one of: "text", "textarea", "integer", "number",
799
+ "checkbox", "date", "datetime", "select", "select-multi",
800
+ "ontology-select", "system-defined" (unless the input field already
801
+ uses another type, which you must keep).
802
+ - For a "select"/"select-multi" field, list the choices INLINE on the field
803
+ as "options": [ { "key": "k", "label": "Label" } ] (a plain string array
804
+ also works); do not set option_layers or a separate select_options block.
805
+ - Renumber "position" values consecutively within each layer and field
806
+ list after additions or removals.
807
+ PROMPT
808
+ end
809
+
810
+ # Refine normally edits an existing template. But when the current template
811
+ # is empty or has no usable fields, "editing" is really a design task — so
812
+ # swap in the generate/design system prompt (with a note to still return the
813
+ # refine "summary" key). This keeps a single user-chosen model and never asks
814
+ # the user to switch to "New (AI)": the code recognises the from-scratch case
815
+ # itself. A populated template keeps the edit prompt.
816
+ def refine_system_content(current)
817
+ parts =
818
+ if refine_from_scratch?(current)
819
+ [system_prompt, from_scratch_note, field_rules_prompt]
820
+ else
821
+ [refine_system_prompt, field_rules_prompt]
822
+ end
823
+ scope = method_scope_prompt
824
+ parts << scope if scope.present?
825
+ parts.join("\n\n")
826
+ end
827
+
828
+ # A dataset template is bound to a CHMO analytical-method term. Give the
829
+ # model that method as authoritative scope so it keeps the template coherent:
830
+ # add fields that fit the method or are general dataset metadata, and decline
831
+ # clearly-unrelated ones (explaining in the summary) instead of blindly adding
832
+ # whatever was asked. Blank when no term is known (e.g. the element/segment
833
+ # refine paths, which are not method-bound), so the guard is dataset-only.
834
+ def method_scope_prompt
835
+ id, label = method_term
836
+ return '' if id.blank? && label.blank?
837
+
838
+ method = label.present? ? "\"#{label}\" (#{id})" : id
839
+ <<~PROMPT.strip
840
+ This #{@kind} template records data for the analytical method #{method}.
841
+ Treat that method as the authoritative scope of the template. When
842
+ applying the change request, add or keep only fields that are relevant to
843
+ this method or are general dataset metadata (operator, date, sample id,
844
+ notes, funding and the like). If the request asks to add fields clearly
845
+ unrelated to this method — neither method-specific nor general metadata —
846
+ do NOT add them; keep the rest of the template valid and state in the
847
+ "summary" what you declined and why.
848
+ PROMPT
849
+ end
850
+
851
+ # Split a stored ols_term_id ("CHMO:0000470 | mass spectrometry") into its id
852
+ # and optional human label; a bare id returns a blank label.
853
+ def method_term
854
+ raw = @ols_term_id.to_s.strip
855
+ return ['', ''] if raw.blank?
856
+
857
+ id, label = raw.split('|', 2)
858
+ [id.to_s.strip, label.to_s.strip]
859
+ end
860
+
861
+ # True when there is nothing meaningful to edit: no layers, or no layer
862
+ # carries any real field. ("Nonsense but populated" is not code-detectable;
863
+ # only structural emptiness is.)
864
+ def refine_from_scratch?(current)
865
+ current = {} unless current.is_a?(Hash)
866
+ layers = current['layers'] || current[:layers] || {}
867
+ values = layers.respond_to?(:values) ? layers.values : Array(layers)
868
+ values.none? { |layer| layer_has_fields?(layer) }
869
+ end
870
+
871
+ def layer_has_fields?(layer)
872
+ return false unless layer.is_a?(Hash)
873
+
874
+ Array(layer['fields'] || layer[:fields]).any? do |f|
875
+ f.is_a?(Hash) && (f['field'] || f[:field]).present?
876
+ end
877
+ end
878
+
879
+ def from_scratch_note
880
+ <<~PROMPT.strip
881
+ The current template is empty or has no usable fields. Treat the change
882
+ request below as a request to DESIGN a complete template from scratch,
883
+ following the design guidance above. In addition to "label", "layers"
884
+ and "select_options", include a one-sentence "summary" key describing
885
+ what you built.
886
+ PROMPT
887
+ end
888
+
889
+ # Tolerate models that wrap JSON in ```json fences despite instructions.
890
+ def parse_json(text)
891
+ cleaned = text.strip
892
+ cleaned = cleaned.sub(/\A```(?:json)?\s*/i, '').sub(/\s*```\z/, '') if cleaned.start_with?('```')
893
+ JSON.parse(cleaned)
894
+ end
895
+
896
+ # Coerce the model output into a schema-valid shape, filling the keys the
897
+ # LabIMotion layers/field-base schemas require (layer: key/cols/color/
898
+ # position; field: field/type) so the template renders even if the model
899
+ # omits them.
900
+ def normalize(data)
901
+ data = {} unless data.is_a?(Hash)
902
+ raw_layers = data['layers'].is_a?(Hash) ? data['layers'] : {}
903
+ layers = {}
904
+ raw_layers.each_with_index do |(key, layer), idx|
905
+ built = normalize_layer(key.to_s, layer, idx)
906
+ carry_group_tag(built, layer) if layer.is_a?(Hash)
907
+ layers[key.to_s] = built
908
+ end
909
+ select_options = data['select_options'].is_a?(Hash) ? data['select_options'].dup : {}
910
+ lift_inline_options!(layers, select_options)
911
+ {
912
+ 'label' => data['label'].to_s.strip,
913
+ 'layers' => layers,
914
+ 'select_options' => select_options,
915
+ 'metadata' => metadata_for(data, layers)
916
+ }
917
+ end
918
+
919
+ def normalize_layer(key, layer, index)
920
+ layer = {} unless layer.is_a?(Hash)
921
+ raw_fields = layer['fields'].is_a?(Array) ? layer['fields'] : []
922
+ fields = raw_fields.each_with_index.map { |field, fidx| normalize_field(field, fidx) }.compact
923
+ {
924
+ 'key' => (layer['key'].presence || key).to_s,
925
+ 'label' => (layer['label'].presence || key).to_s,
926
+ 'cols' => layer['cols'].is_a?(Integer) ? layer['cols'].clamp(1, 6) : @cols,
927
+ 'color' => layer['color'].presence || 'default',
928
+ 'position' => layer['position'].is_a?(Integer) ? layer['position'] : (index + 1),
929
+ 'fields' => fields
930
+ }
931
+ end
932
+
933
+ # Copy a model layer's UI-section "group" tag onto the normalized layer (the
934
+ # generate path allowlists layer keys); build_metadata_groups then lifts it.
935
+ def carry_group_tag(result, layer)
936
+ group = layer['group']
937
+ result['group'] = group.to_s if group.is_a?(String) && group.present?
938
+ end
939
+
940
+ def normalize_field(field, position)
941
+ return nil unless field.is_a?(Hash)
942
+
943
+ name = field['field'].presence
944
+ return nil if name.blank?
945
+
946
+ result = {
947
+ 'field' => name.to_s,
948
+ 'type' => FIELD_TYPES.include?(field['type']) ? field['type'] : 'text',
949
+ 'label' => (field['label'].presence || name).to_s,
950
+ 'position' => field['position'].is_a?(Integer) ? field['position'] : (position + 1),
951
+ 'required' => field['required'] == true
952
+ }
953
+ result['default'] = field['default'].to_s if field['default'].present?
954
+ # option_layers = unit-group key (system-defined) OR select_options key
955
+ # (select / select-multi). Keep a STRING one; value_system is the default unit
956
+ # for a system-defined field.
957
+ ol = field['option_layers']
958
+ result['option_layers'] = ol.to_s if ol.is_a?(String) && ol.present?
959
+ result['value_system'] = field['value_system'].to_s if field['value_system'].present?
960
+ # Carry an inline options list forward (or an array a model wrongly put in
961
+ # option_layers) so lift_inline_options! folds it into select_options.
962
+ inline = field['options']
963
+ inline = ol if inline.nil? && ol.is_a?(Array)
964
+ result['options'] = inline if inline.is_a?(Array)
965
+ apply_unit_mapping(result, field)
966
+ apply_table_columns(result, field)
967
+ result
968
+ end
969
+
970
+ # Refine variant of #normalize that PRESERVES any extra layer/field keys the
971
+ # model echoes back (units, sub_fields, restrictions, cond_fields, ...) so
972
+ # editing an existing rich template does not silently drop advanced
973
+ # properties. It only enforces the required keys on top of the model output.
974
+ def normalize_preserving(data)
975
+ data = {} unless data.is_a?(Hash)
976
+ raw_layers = data['layers'].is_a?(Hash) ? data['layers'] : {}
977
+ layers = {}
978
+ raw_layers.each_with_index do |(key, layer), idx|
979
+ layers[key.to_s] = normalize_layer_preserving(key.to_s, layer, idx)
980
+ end
981
+ select_options = data['select_options'].is_a?(Hash) ? data['select_options'].dup : {}
982
+ lift_inline_options!(layers, select_options)
983
+ {
984
+ 'label' => data['label'].to_s.strip,
985
+ 'layers' => layers,
986
+ 'select_options' => select_options,
987
+ 'metadata' => metadata_for(data, layers)
988
+ }
989
+ end
990
+
991
+ def normalize_layer_preserving(key, layer, index)
992
+ layer = {} unless layer.is_a?(Hash)
993
+ raw_fields = layer['fields'].is_a?(Array) ? layer['fields'] : []
994
+ fields = raw_fields.each_with_index.map { |field, fidx| normalize_field_preserving(field, fidx) }.compact
995
+ layer.merge(
996
+ 'key' => (layer['key'].presence || key).to_s,
997
+ 'label' => (layer['label'].presence || key).to_s,
998
+ 'cols' => layer['cols'].is_a?(Integer) ? layer['cols'].clamp(1, 6) : @cols,
999
+ 'color' => layer['color'].presence || 'default',
1000
+ 'position' => layer['position'].is_a?(Integer) ? layer['position'] : (index + 1),
1001
+ 'fields' => fields
1002
+ )
1003
+ end
1004
+
1005
+ def normalize_field_preserving(field, position)
1006
+ return nil unless field.is_a?(Hash)
1007
+
1008
+ name = field['field'].presence
1009
+ return nil if name.blank?
1010
+
1011
+ # Keep the model's type verbatim (an existing template may legitimately use
1012
+ # a type outside FIELD_TYPES, e.g. "table"); only default it when blank.
1013
+ result = field.merge(
1014
+ 'field' => name.to_s,
1015
+ 'type' => (field['type'].presence || 'text').to_s,
1016
+ 'label' => (field['label'].presence || name).to_s,
1017
+ 'position' => field['position'].is_a?(Integer) ? field['position'] : (position + 1),
1018
+ 'required' => field['required'] == true
1019
+ )
1020
+ apply_unit_mapping(result, field)
1021
+ apply_table_columns(result, field)
1022
+ result
1023
+ end
1024
+
1025
+ # Fold each select/select-multi field's inline `options` into the shared
1026
+ # select_options map: generate a stable key, register { options: [{key,label}] },
1027
+ # point the field's option_layers at it, and drop the inline copy. Lets the model
1028
+ # return choices inline (which weak models get right far more often) instead of
1029
+ # maintaining the error-prone option_layers <-> select_options cross-reference.
1030
+ def lift_inline_options!(layers, select_options)
1031
+ layers.each_value do |layer|
1032
+ next unless layer.is_a?(Hash)
1033
+
1034
+ Array(layer['fields']).each { |field| lift_field_options!(field, select_options) }
1035
+ end
1036
+ end
1037
+
1038
+ def lift_field_options!(field, select_options)
1039
+ return unless field.is_a?(Hash)
1040
+
1041
+ inline = field.delete('options')
1042
+ return unless SELECT_TYPES.include?(field['type'].to_s)
1043
+ return if resolved_option_layers?(field, select_options)
1044
+
1045
+ pairs = normalize_option_pairs(inline || array_option_layers(field))
1046
+ return if pairs.empty?
1047
+
1048
+ key = unique_option_key(field['field'].to_s, select_options)
1049
+ select_options[key] = { 'options' => pairs }
1050
+ field['option_layers'] = key
1051
+ end
1052
+
1053
+ # True when the field already points at a real select_options entry (the model
1054
+ # produced the full cross-reference itself) — leave that untouched.
1055
+ def resolved_option_layers?(field, select_options)
1056
+ ol = field['option_layers']
1057
+ ol.is_a?(String) && ol.present? && select_options[ol].is_a?(Hash)
1058
+ end
1059
+
1060
+ # A model that inlines choices may put the array in option_layers by mistake.
1061
+ def array_option_layers(field)
1062
+ ol = field['option_layers']
1063
+ ol.is_a?(Array) ? ol : nil
1064
+ end
1065
+
1066
+ # Coerce inline options — ["A","B"] or [{key,label}] / [{value,label}] — into
1067
+ # [{'key'=>..,'label'=>..}] pairs; blanks dropped, keys de-duplicated.
1068
+ def normalize_option_pairs(options)
1069
+ return [] unless options.is_a?(Array)
1070
+
1071
+ seen = {}
1072
+ options.each_with_object([]) do |opt, acc|
1073
+ key, label = option_key_label(opt)
1074
+ next if key.empty? || seen[key]
1075
+
1076
+ seen[key] = true
1077
+ acc << { 'key' => key, 'label' => label.presence || key }
1078
+ end
1079
+ end
1080
+
1081
+ def option_key_label(opt)
1082
+ if opt.is_a?(Hash)
1083
+ key = (opt['key'] || opt['value'] || opt['label']).to_s.strip
1084
+ [key, (opt['label'].presence || key).to_s.strip]
1085
+ else
1086
+ str = opt.to_s.strip
1087
+ [str, str]
1088
+ end
1089
+ end
1090
+
1091
+ # A select_options key from the field name, numerically suffixed if the base is
1092
+ # taken, so two fields never collide.
1093
+ def unique_option_key(field_name, select_options)
1094
+ base = "#{field_name}_options"
1095
+ key = base
1096
+ n = 2
1097
+ while select_options.key?(key)
1098
+ key = "#{base}_#{n}"
1099
+ n += 1
1100
+ end
1101
+ key
1102
+ end
1103
+
1104
+ # Turn a model-supplied physical unit into a system-defined unit group. The
1105
+ # model marks a measured number with "unit": "<symbol>"; here we resolve it to
1106
+ # option_layers (group) + value_system (the specific unit) so the field renders
1107
+ # with the right unit picker. Unknown/ambiguous units fall back to plain
1108
+ # "number"; an already-valid system-defined field is left untouched.
1109
+ def apply_unit_mapping(result, raw)
1110
+ result.delete('unit') if result.key?('unit')
1111
+ return if unit_group_ready?(result)
1112
+ return unless UNIT_CANDIDATE_TYPES.include?(result['type'].to_s)
1113
+
1114
+ unit = (raw['unit'] || raw[:unit]).to_s.strip
1115
+ resolved = unit.empty? ? nil : resolve_unit(unit)
1116
+ resolved ? assign_unit_group(result, resolved) : fallback_from_system_defined(result, unit)
1117
+ end
1118
+
1119
+ # True when the field already declares a real unit group (the current shape a
1120
+ # strong model or an edited template produces) — do not second-guess it.
1121
+ def unit_group_ready?(result)
1122
+ result['type'] == 'system-defined' && valid_unit_group?(result['option_layers'])
1123
+ end
1124
+
1125
+ def assign_unit_group(result, resolved)
1126
+ result['type'] = 'system-defined'
1127
+ result['option_layers'] = resolved[:group]
1128
+ result['value_system'] = resolved[:value_system]
1129
+ end
1130
+
1131
+ # A "system-defined" field whose unit we could not map would render broken, so
1132
+ # downgrade it to a plain number and log the raw unit for diagnosis.
1133
+ def fallback_from_system_defined(result, unit)
1134
+ return unless result['type'] == 'system-defined'
1135
+
1136
+ log_ai_response("unmapped unit '#{unit}' on field '#{result['field']}' -> number", unit)
1137
+ result['type'] = 'number'
1138
+ result.delete('option_layers')
1139
+ result.delete('value_system')
1140
+ end
1141
+
1142
+ def resolve_unit(token)
1143
+ self.class.unit_index[self.class.normalize_unit_token(token)]
1144
+ end
1145
+
1146
+ def valid_unit_group?(key)
1147
+ key.is_a?(String) && self.class.unit_groups.include?(key)
1148
+ end
1149
+
1150
+ # Build a "table" field's sub_fields (columns) from the model's inline `columns`
1151
+ # (or normalize an echoed sub_fields array on refine). Each column becomes
1152
+ # { id, type, value:"", col_name } — the shape chem-generic-ui renders. Also sets
1153
+ # text_sub_fields:[]. Non-table fields are untouched.
1154
+ def apply_table_columns(result, raw)
1155
+ return unless result['type'].to_s == 'table'
1156
+
1157
+ result['sub_fields'] = table_sub_fields(raw)
1158
+ result['text_sub_fields'] = [] unless result['text_sub_fields'].is_a?(Array)
1159
+ result.delete('columns')
1160
+ end
1161
+
1162
+ def table_sub_fields(raw)
1163
+ cols = raw['columns'] || raw[:columns]
1164
+ return build_table_columns(cols) if cols.is_a?(Array)
1165
+
1166
+ existing = raw['sub_fields'] || raw[:sub_fields]
1167
+ existing.is_a?(Array) ? keep_table_columns(existing) : []
1168
+ end
1169
+
1170
+ def build_table_columns(cols)
1171
+ seen = {}
1172
+ cols.filter_map do |col|
1173
+ name, type = table_col_name_type(col)
1174
+ next if name.empty? || seen[name]
1175
+
1176
+ seen[name] = true
1177
+ { 'id' => SecureRandom.uuid, 'type' => type, 'value' => '', 'col_name' => name }
1178
+ end
1179
+ end
1180
+
1181
+ def table_col_name_type(col)
1182
+ return [col.to_s.strip, 'text'] unless col.is_a?(Hash)
1183
+
1184
+ name = (col['name'] || col['col_name'] || col['label'] || col['field']).to_s.strip
1185
+ type = col['type'].to_s
1186
+ [name, TABLE_COL_TYPES.include?(type) ? type : 'text']
1187
+ end
1188
+
1189
+ # Keep an existing table's columns (refine echo), enforcing id/type/value/col_name
1190
+ # while preserving any extra column keys (option_layers, value_system, ...).
1191
+ def keep_table_columns(sub_fields)
1192
+ sub_fields.filter_map do |sf|
1193
+ next unless sf.is_a?(Hash)
1194
+
1195
+ name = (sf['col_name'] || sf[:col_name]).to_s.strip
1196
+ next if name.empty?
1197
+
1198
+ sf.merge('id' => (sf['id'].presence || SecureRandom.uuid).to_s,
1199
+ 'type' => (sf['type'].presence || 'text').to_s,
1200
+ 'value' => sf['value'].to_s, 'col_name' => name)
1201
+ end
1202
+ end
1203
+
1204
+ # The metadata.groups block (UI sectioning). Built from the per-layer "group"
1205
+ # tag the model attaches; falls back to an existing metadata block (refine, when
1206
+ # no layer is tagged). {} when neither is present.
1207
+ def metadata_for(data, layers)
1208
+ grouped = build_metadata_groups(layers)
1209
+ return grouped unless grouped.empty?
1210
+
1211
+ existing = data['metadata'] || data[:metadata]
1212
+ existing.is_a?(Hash) ? existing : {}
1213
+ end
1214
+
1215
+ # Collect layers by their "group" tag (first-seen order = section order) into a
1216
+ # schema-valid metadata.groups array, and strip the tag off each layer. Returns
1217
+ # {} when no layer is grouped.
1218
+ def build_metadata_groups(layers)
1219
+ members = collect_layer_groups(layers)
1220
+ return {} if members.empty?
1221
+
1222
+ groups = members.keys.each_with_index.map do |label, idx|
1223
+ { 'id' => group_id(label, idx), 'label' => label, 'position' => idx + 1, 'layers' => members[label] }
1224
+ end
1225
+ { 'groups' => groups, 'restrict' => nil }
1226
+ end
1227
+
1228
+ # layer "group" tag -> ordered { label => [layer_keys] }, stripping the tag.
1229
+ # Ruby preserves insertion order, so the first-seen group is the first section.
1230
+ def collect_layer_groups(layers)
1231
+ members = {}
1232
+ layers.each_value do |layer|
1233
+ label = (layer.is_a?(Hash) ? layer.delete('group') : nil).to_s.strip
1234
+ next if label.empty?
1235
+
1236
+ (members[label] ||= []) << layer['key'].to_s
1237
+ end
1238
+ members
1239
+ end
1240
+
1241
+ # A slug id for a metadata group label; index keeps it non-empty and stable.
1242
+ def group_id(label, idx)
1243
+ slug = label.downcase.gsub(/[^a-z0-9]+/, '_').gsub(/\A_+|_+\z/, '')
1244
+ slug.empty? ? "group_#{idx + 1}" : slug
1245
+ end
1246
+
1247
+ # ---- data-fill (value extraction) -------------------------------------
1248
+
1249
+ # Chat messages for a data-fill request: a value-extraction system prompt,
1250
+ # then the compact field schema + the document text in the user turn.
1251
+ def fill_messages(schema, context_text)
1252
+ [
1253
+ { role: 'system', content: fill_system_prompt },
1254
+ { role: 'user', content: fill_user_prompt(schema, context_text) }
1255
+ ]
1256
+ end
1257
+
1258
+ def fill_system_prompt
1259
+ cfg[:fill_system_prompt].presence || default_fill_system_prompt
1260
+ end
1261
+
1262
+ def default_fill_system_prompt
1263
+ <<~PROMPT
1264
+ You are an assistant that extracts structured metadata VALUES from a
1265
+ scientific document to pre-fill a LabIMotion generic element's form fields.
1266
+
1267
+ You are given (1) a compact schema of the element's fields, grouped by
1268
+ layer, and (2) the text extracted from a document. Read the document and
1269
+ return the values you can find for those fields.
1270
+
1271
+ Respond with a SINGLE JSON object only — no prose, no markdown, no code
1272
+ fences — of exactly this shape:
1273
+
1274
+ { "values": { "<layer_key>": { "<field_key>": <value> } },
1275
+ "summary": "<one short sentence on what you found>" }
1276
+
1277
+ Rules:
1278
+ - Only include a field whose value you ACTUALLY FIND in the document. OMIT
1279
+ every other field. Never invent, guess or infer a value the text does not
1280
+ support. When unsure, leave the field out.
1281
+ - Use the exact layer_key and field_key from the schema.
1282
+ - "integer" / "number" / "system-defined" -> a JSON number only (no unit
1283
+ text; the unit is fixed by the template).
1284
+ - "checkbox" -> true or false.
1285
+ - "date" -> "YYYY-MM-DD". "datetime" -> ISO-8601.
1286
+ - "select" / "select-multi": each such field lists its allowed options as
1287
+ {"key","label"} pairs. Match the document wording against an option's
1288
+ "label" (or "key") and return the option's "key" — a single key for
1289
+ "select", an array of keys for "select-multi". Only use keys from that
1290
+ field's own options; omit the field if none apply.
1291
+ - "text" / "textarea" -> a string.
1292
+ - If you find no values at all, return { "values": {}, "summary": "..." }.
1293
+ PROMPT
1294
+ end
1295
+
1296
+ def fill_user_prompt(schema, context_text)
1297
+ <<~PROMPT
1298
+ Element field schema (JSON):
1299
+ #{JSON.generate(schema)}
1300
+
1301
+ Document text:
1302
+ #{context_text}
1303
+
1304
+ Extract the field values now and return only the JSON object.
1305
+ PROMPT
1306
+ end
1307
+
1308
+ # A COMPACT schema of the template's fillable fields, grouped by layer:
1309
+ # [{ 'layer' => k, 'label' => l, 'fields' => [{ 'field', 'label', 'type',
1310
+ # ('options' => [{ 'key', 'label' }] for select/select-multi) }] }]. Layers
1311
+ # with no fields are dropped so the model isn't shown empty groups.
1312
+ def fill_field_schema(properties)
1313
+ layers = properties['layers'].is_a?(Hash) ? properties['layers'] : {}
1314
+ select_options = properties['select_options'].is_a?(Hash) ? properties['select_options'] : {}
1315
+ schema = []
1316
+ layers.each do |layer_key, layer|
1317
+ next unless layer.is_a?(Hash)
1318
+
1319
+ raw_fields = layer['fields'].is_a?(Array) ? layer['fields'] : []
1320
+ fields = raw_fields.map { |field| fill_schema_field(field, select_options) }.compact
1321
+ next if fields.empty?
1322
+
1323
+ schema << {
1324
+ 'layer' => layer_key.to_s,
1325
+ 'label' => (layer['label'].presence || layer_key).to_s,
1326
+ 'fields' => fields
1327
+ }
1328
+ end
1329
+ schema
1330
+ end
1331
+
1332
+ def fill_schema_field(field, select_options)
1333
+ return nil unless field.is_a?(Hash)
1334
+
1335
+ name = field['field'].presence
1336
+ return nil if name.blank?
1337
+
1338
+ type = field['type'].to_s
1339
+ entry = { 'field' => name.to_s, 'label' => (field['label'].presence || name).to_s, 'type' => type }
1340
+ entry['options'] = fill_option_pairs(select_options, field) if %w[select select-multi].include?(type)
1341
+ entry
1342
+ end
1343
+
1344
+ # Allowed options for a select/select-multi field as { 'key' => .., 'label' => .. }
1345
+ # pairs, read from properties['select_options'][field['option_layers']]['options'].
1346
+ # Labels are sent to the model so it can map the document wording (which is
1347
+ # phrased like the human-readable label, not the machine key) to an option.
1348
+ def fill_option_pairs(select_options, field)
1349
+ group = select_options[field['option_layers']]
1350
+ options = group.is_a?(Hash) ? group['options'] : nil
1351
+ return [] unless options.is_a?(Array)
1352
+
1353
+ options.filter_map do |opt|
1354
+ next unless opt.is_a?(Hash)
1355
+
1356
+ opt_key = opt['key'].presence
1357
+ next if opt_key.nil?
1358
+
1359
+ { 'key' => opt_key.to_s, 'label' => (opt['label'].presence || opt_key).to_s }
1360
+ end
1361
+ end
1362
+
1363
+ # Map a model answer — which may be an option KEY or the human LABEL — back to
1364
+ # the option key. Exact key wins, then a case-insensitive label-or-key match;
1365
+ # nil when nothing matches (the value is dropped upstream).
1366
+ def resolve_option_key(pairs, candidate)
1367
+ cand = candidate.to_s.strip
1368
+ return nil if cand.empty?
1369
+
1370
+ exact = pairs.find { |p| p['key'] == cand }
1371
+ return exact['key'] if exact
1372
+
1373
+ ci = cand.downcase
1374
+ match = pairs.find { |p| p['label'].downcase == ci || p['key'].downcase == ci }
1375
+ match && match['key']
1376
+ end
1377
+
1378
+ # Validate the model's { layer => { field => value } } against the template:
1379
+ # keep only existing layer/field keys, coerce per field type, drop select
1380
+ # values outside the allowed option keys, and drop nil/blank values.
1381
+ def normalize_fill_values(properties, raw_values)
1382
+ return {} unless raw_values.is_a?(Hash)
1383
+
1384
+ layers = properties['layers'].is_a?(Hash) ? properties['layers'] : {}
1385
+ select_options = properties['select_options'].is_a?(Hash) ? properties['select_options'] : {}
1386
+ result = {}
1387
+ raw_values.each do |layer_key, fields|
1388
+ layer = layers[layer_key.to_s]
1389
+ next unless layer.is_a?(Hash) && fields.is_a?(Hash)
1390
+
1391
+ field_defs = fill_field_defs(layer)
1392
+ clean = {}
1393
+ fields.each do |field_key, value|
1394
+ field = field_defs[field_key.to_s]
1395
+ next if field.nil?
1396
+
1397
+ coerced = coerce_fill_value(field, value, select_options)
1398
+ next if fill_blank?(coerced)
1399
+
1400
+ clean[field_key.to_s] = coerced
1401
+ end
1402
+ result[layer_key.to_s] = clean unless clean.empty?
1403
+ end
1404
+ result
1405
+ end
1406
+
1407
+ def fill_field_defs(layer)
1408
+ raw_fields = layer['fields'].is_a?(Array) ? layer['fields'] : []
1409
+ raw_fields.each_with_object({}) do |field, memo|
1410
+ next unless field.is_a?(Hash)
1411
+
1412
+ name = field['field'].presence
1413
+ memo[name.to_s] = field if name.present?
1414
+ end
1415
+ end
1416
+
1417
+ def fill_blank?(value)
1418
+ return true if value.nil?
1419
+ return true if value.is_a?(String) && value.strip.empty?
1420
+ return true if value.is_a?(Array) && value.empty?
1421
+
1422
+ false
1423
+ end
1424
+
1425
+ def coerce_fill_value(field, value, select_options)
1426
+ return nil if value.nil?
1427
+
1428
+ case field['type'].to_s
1429
+ when 'integer'
1430
+ num = fill_to_number(value)
1431
+ num.nil? ? nil : num.to_i
1432
+ when 'number', 'system-defined'
1433
+ fill_to_number(value)
1434
+ when 'checkbox'
1435
+ fill_to_boolean(value)
1436
+ when 'select'
1437
+ pairs = fill_option_pairs(select_options, field)
1438
+ candidate = value.is_a?(Array) ? value.first : value
1439
+ resolve_option_key(pairs, candidate)
1440
+ when 'select-multi'
1441
+ pairs = fill_option_pairs(select_options, field)
1442
+ list = value.is_a?(Array) ? value : [value]
1443
+ list.filter_map { |v| resolve_option_key(pairs, v) }.uniq
1444
+ else
1445
+ # text / textarea / date / datetime / ontology-select / label / other:
1446
+ # keep scalars as a string; drop objects/arrays we can't render.
1447
+ return value.to_s if value.is_a?(String) || value.is_a?(Numeric)
1448
+ return value.to_s if value == true || value == false
1449
+
1450
+ nil
1451
+ end
1452
+ end
1453
+
1454
+ def fill_to_number(value)
1455
+ case value
1456
+ when Numeric
1457
+ value
1458
+ when String
1459
+ str = value.strip
1460
+ return nil if str.empty?
1461
+ return str.to_i if str.match?(/\A[+-]?\d+\z/)
1462
+ return str.to_f if str.match?(/\A[+-]?\d*\.?\d+(?:[eE][+-]?\d+)?\z/)
1463
+
1464
+ nil
1465
+ end
1466
+ end
1467
+
1468
+ def fill_to_boolean(value)
1469
+ case value
1470
+ when true, false
1471
+ value
1472
+ when Numeric
1473
+ !value.zero?
1474
+ when String
1475
+ case value.strip.downcase
1476
+ when 'true', 'yes', '1' then true
1477
+ when 'false', 'no', '0' then false
1478
+ end
1479
+ end
1480
+ end
1481
+ end
1482
+ end