labimotion 2.4.0.rc2 → 2.4.0.rc4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/lib/labimotion/apis/generic_dataset_api.rb +41 -3
- data/lib/labimotion/apis/generic_element_api.rb +60 -8
- data/lib/labimotion/apis/generic_klass_api.rb +50 -6
- data/lib/labimotion/apis/klass_share_api.rb +646 -0
- data/lib/labimotion/apis/labimotion_api.rb +1 -0
- data/lib/labimotion/apis/labimotion_doi_api.rb +24 -10
- data/lib/labimotion/apis/labimotion_template_browse_api.rb +13 -1
- data/lib/labimotion/apis/segment_api.rb +61 -10
- data/lib/labimotion/constants.rb +14 -0
- data/lib/labimotion/entities/application_entity.rb +13 -1
- data/lib/labimotion/entities/generic_klass_entity.rb +125 -0
- data/lib/labimotion/entities/klass_share_entity.rb +48 -0
- data/lib/labimotion/helpers/dataset_helpers.rb +123 -1
- data/lib/labimotion/helpers/element_helpers.rb +206 -4
- data/lib/labimotion/helpers/generic_helpers.rb +276 -4
- data/lib/labimotion/helpers/param_helpers.rb +68 -0
- data/lib/labimotion/helpers/segment_helpers.rb +87 -4
- data/lib/labimotion/libs/ai_egress_guard.rb +84 -0
- data/lib/labimotion/libs/ai_template.rb +1482 -0
- data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
- data/lib/labimotion/libs/file_extractor.rb +210 -0
- data/lib/labimotion/libs/owner_resolver.rb +50 -0
- data/lib/labimotion/libs/ownership_audit.rb +73 -0
- data/lib/labimotion/libs/share_notifier.rb +114 -0
- data/lib/labimotion/libs/share_resolver.rb +369 -0
- data/lib/labimotion/models/concerns/template_doi.rb +133 -0
- data/lib/labimotion/models/dataset_klass.rb +1 -1
- data/lib/labimotion/models/element_klass.rb +1 -1
- data/lib/labimotion/models/klass_share.rb +126 -0
- data/lib/labimotion/models/segment_klass.rb +1 -1
- data/lib/labimotion/usecases/build_template_doi_xml.rb +69 -23
- data/lib/labimotion/usecases/release_template_doi.rb +42 -17
- data/lib/labimotion/usecases/template_doi_helpers.rb +28 -6
- data/lib/labimotion/usecases/update_template_publication_metadata.rb +71 -2
- data/lib/labimotion/utils/prop.rb +1 -0
- data/lib/labimotion/version.rb +1 -1
- data/lib/labimotion.rb +11 -0
- metadata +28 -2
|
@@ -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
|