labimotion 2.4.0.rc9 → 2.4.0.rc11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/labimotion/apis/generic_dataset_api.rb +52 -0
- data/lib/labimotion/apis/generic_element_api.rb +15 -0
- data/lib/labimotion/apis/labimotion_ai_api.rb +16 -9
- data/lib/labimotion/apis/segment_api.rb +16 -0
- data/lib/labimotion/helpers/dataset_helpers.rb +73 -9
- data/lib/labimotion/helpers/element_helpers.rb +18 -3
- data/lib/labimotion/helpers/param_helpers.rb +13 -0
- data/lib/labimotion/helpers/segment_helpers.rb +17 -2
- data/lib/labimotion/libs/ai_egress_guard.rb +18 -3
- data/lib/labimotion/libs/ai_klass_queue.rb +88 -0
- data/lib/labimotion/libs/ai_klass_validator.rb +74 -0
- data/lib/labimotion/libs/ai_template.rb +409 -14
- data/lib/labimotion/version.rb +1 -1
- data/lib/labimotion.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cc8a97c07b6329e1a6be6097c923d710101dfe03494e98463ab2795cea02a590
|
|
4
|
+
data.tar.gz: 7d5535979919fe5709feed7a00860ba6ac24c6c69999e3b1e9f0c03819b3268e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 79b2acc5223830bb6c4444342173b80af2ad4611f095e07cfcc89861473cf5f2e2854b0ed67e098b8c9bd94fe9285178f06fb20c7ae84729d5938d567a490531
|
|
7
|
+
data.tar.gz: b657890bf6c80d13283ba1936678c2c364308e80ba0cf01b51f36d129012873c47b2a9393724d2154307ba1ee267708a00992a49038428c3abe8160ef607eeb2
|
|
@@ -85,6 +85,21 @@ module Labimotion
|
|
|
85
85
|
use :create_ai_dataset_klass_params
|
|
86
86
|
end
|
|
87
87
|
post do
|
|
88
|
+
# Settle the cheap objections BEFORE spending a provider call on this:
|
|
89
|
+
# a taken name is knowable now, and finding out after generation means
|
|
90
|
+
# 15-90s and a token spend for an answer a lookup could have given.
|
|
91
|
+
validation = Labimotion::AiKlassValidator.error_for(kind: 'dataset', params: params)
|
|
92
|
+
next { status: 'error', message: validation } if validation
|
|
93
|
+
|
|
94
|
+
# Generation is a long provider call, so it is handed to the host's worker
|
|
95
|
+
# and the request answers at once; the result arrives as a notification
|
|
96
|
+
# linking back into the designer. A host with no such worker gets the
|
|
97
|
+
# old inline behaviour rather than an error.
|
|
98
|
+
if Labimotion::AiKlassQueue.queue(kind: 'dataset', params: params, user: current_user)
|
|
99
|
+
next { status: 'queued',
|
|
100
|
+
message: 'Generating the template in the background. You will be notified when it is ready.' }
|
|
101
|
+
end
|
|
102
|
+
|
|
88
103
|
msg = create_ai_dataset_klass(params, current_user)
|
|
89
104
|
klass = Labimotion::DatasetKlassEntity.represent(Labimotion::DatasetKlass.all)
|
|
90
105
|
{ status: msg[:status], message: msg[:message], klass: klass }
|
|
@@ -107,6 +122,43 @@ module Labimotion
|
|
|
107
122
|
end
|
|
108
123
|
end
|
|
109
124
|
|
|
125
|
+
namespace :plan_ai_klass do
|
|
126
|
+
# The cheap half of fine-tuning: an instruction plus an INDEX of the open
|
|
127
|
+
# template in, a short list of structural operations out, which the
|
|
128
|
+
# designer applies with the handlers it already has. Type-agnostic like
|
|
129
|
+
# :refine_ai_klass beside it — the path reads as dataset only because
|
|
130
|
+
# that is where this family of routes was first added.
|
|
131
|
+
#
|
|
132
|
+
# Gated on the same whitelist as the AI settings API
|
|
133
|
+
# (Labimotion::MatriceLabimotion, via the host's model): this endpoint
|
|
134
|
+
# spends the user's AI budget, so reaching it needs more than being
|
|
135
|
+
# logged in. NameError, not defined?, for the reason spelled out at
|
|
136
|
+
# generic_element_api's :ai_fill_data — under Zeitwerk `defined?` is nil
|
|
137
|
+
# until the constant is first referenced, and a host that ships no such
|
|
138
|
+
# model is a refusal too.
|
|
139
|
+
before do
|
|
140
|
+
allowed = begin
|
|
141
|
+
Matrice.ai_enabled_for_any?(current_user)
|
|
142
|
+
rescue NameError
|
|
143
|
+
false
|
|
144
|
+
end
|
|
145
|
+
unless allowed
|
|
146
|
+
error!({ status: 'error', message: 'AI template editing is not enabled for this account.' }, 403)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
desc 'plan structural changes to a template with AI (returns operations for the designer to apply)'
|
|
151
|
+
params do
|
|
152
|
+
use :plan_ai_dataset_klass_params
|
|
153
|
+
end
|
|
154
|
+
post do
|
|
155
|
+
plan_ai_dataset_klass(params, current_user)
|
|
156
|
+
rescue StandardError => e
|
|
157
|
+
Labimotion.log_exception(e, current_user)
|
|
158
|
+
{ status: 'error', message: e.message }
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
110
162
|
namespace :find_template do
|
|
111
163
|
desc 'Find best matching template for given OLS term ID'
|
|
112
164
|
params do
|
|
@@ -219,6 +219,21 @@ module Labimotion
|
|
|
219
219
|
end
|
|
220
220
|
post do
|
|
221
221
|
authenticate_admin!('elements')
|
|
222
|
+
# Settle the cheap objections BEFORE spending a provider call on this:
|
|
223
|
+
# a taken name is knowable now, and finding out after generation means
|
|
224
|
+
# 15-90s and a token spend for an answer a lookup could have given.
|
|
225
|
+
validation = Labimotion::AiKlassValidator.error_for(kind: 'element', params: params)
|
|
226
|
+
next { status: 'error', message: validation } if validation
|
|
227
|
+
|
|
228
|
+
# Generation is a long provider call, so it is handed to the host's worker
|
|
229
|
+
# and the request answers at once; the result arrives as a notification
|
|
230
|
+
# linking back into the designer. A host with no such worker gets the
|
|
231
|
+
# old inline behaviour rather than an error.
|
|
232
|
+
if Labimotion::AiKlassQueue.queue(kind: 'element', params: params, user: current_user)
|
|
233
|
+
next { status: 'queued',
|
|
234
|
+
message: 'Generating the template in the background. You will be notified when it is ready.' }
|
|
235
|
+
end
|
|
236
|
+
|
|
222
237
|
msg = create_ai_element_klass(params, current_user)
|
|
223
238
|
{ status: msg[:status], message: msg[:message],
|
|
224
239
|
klass: Labimotion::ElementKlassEntity.represent(Labimotion::ElementKlass.where(is_active: true)) }
|
|
@@ -17,6 +17,7 @@ module Labimotion
|
|
|
17
17
|
# :models) lists none. A representative subset of the chat-capable KI-Toolbox
|
|
18
18
|
# models (ki-toolbox.scc.kit.edu); the full list lives in the yml.
|
|
19
19
|
DEFAULT_MODELS = %w[
|
|
20
|
+
models/DeepSeek-R1-Distill-Qwen-1.5B-Q4_K_M.gguf
|
|
20
21
|
kit.mistral-small-4-119b-a8b
|
|
21
22
|
azure.gpt-4.1-mini
|
|
22
23
|
azure.gpt-4.1
|
|
@@ -99,18 +100,23 @@ module Labimotion
|
|
|
99
100
|
(ids << ENV['KI_TOOLBOX_MODEL'].to_s).compact_blank.uniq
|
|
100
101
|
end
|
|
101
102
|
|
|
102
|
-
# A well-formed
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
|
|
103
|
+
# A well-formed URL with a host, https only. In non-production envs
|
|
104
|
+
# (dev/test against local providers) http is also accepted. The deep
|
|
105
|
+
# SSRF check (host resolves to a public address) runs server-side in
|
|
106
|
+
# the gem at request time; this is only a fast format check for
|
|
107
|
+
# immediate UI feedback.
|
|
108
|
+
def valid_ai_endpoint?(url)
|
|
106
109
|
uri = URI.parse(url.to_s.strip)
|
|
107
|
-
|
|
110
|
+
return false unless uri.host.present?
|
|
111
|
+
|
|
112
|
+
Rails.env.production? ? uri.is_a?(URI::HTTPS) : uri.is_a?(URI::HTTP)
|
|
108
113
|
rescue URI::InvalidURIError
|
|
109
114
|
false
|
|
110
115
|
end
|
|
111
116
|
|
|
112
117
|
# A personal provider endpoint is only usable with a personal key, and
|
|
113
|
-
# must be a valid https URL
|
|
118
|
+
# must be a valid https URL (http allowed outside production). Raises
|
|
119
|
+
# a 422 otherwise; no-op when unset.
|
|
114
120
|
def validate_ai_endpoint!(base_url_arg, will_have_key)
|
|
115
121
|
return if base_url_arg.blank?
|
|
116
122
|
|
|
@@ -120,9 +126,10 @@ module Labimotion
|
|
|
120
126
|
422
|
|
121
127
|
)
|
|
122
128
|
end
|
|
123
|
-
return if
|
|
129
|
+
return if valid_ai_endpoint?(base_url_arg)
|
|
124
130
|
|
|
125
|
-
|
|
131
|
+
scheme_msg = Rails.env.production? ? 'https://' : 'http:// or https://'
|
|
132
|
+
error!({ status: false, error: "The AI provider endpoint must be a valid #{scheme_msg} URL." }, 422)
|
|
126
133
|
end
|
|
127
134
|
end
|
|
128
135
|
|
|
@@ -136,7 +143,7 @@ module Labimotion
|
|
|
136
143
|
base_url: settings.base_url,
|
|
137
144
|
api_path: settings.api_path,
|
|
138
145
|
available_models: labimotion_ai_models,
|
|
139
|
-
default_model: (configured && configured[:model]).presence,
|
|
146
|
+
default_model: (configured && configured[:model]).presence || DEFAULT_MODELS.first,
|
|
140
147
|
default_base_url: (configured && configured[:base_url]).presence,
|
|
141
148
|
server_api_key_set: (configured && configured[:api_key]).present?
|
|
142
149
|
}
|
|
@@ -69,6 +69,22 @@ module Labimotion
|
|
|
69
69
|
@klass = fetch_klass('ElementKlass', params[:element_klass])
|
|
70
70
|
end
|
|
71
71
|
post do
|
|
72
|
+
# Settle the cheap objections BEFORE spending a provider call on this:
|
|
73
|
+
# a taken name is knowable now, and finding out after generation means
|
|
74
|
+
# 15-90s and a token spend for an answer a lookup could have given.
|
|
75
|
+
validation = Labimotion::AiKlassValidator.error_for(kind: 'segment', params: params, element_klass: @klass)
|
|
76
|
+
next { status: 'error', message: validation } if validation
|
|
77
|
+
|
|
78
|
+
# Generation is a long provider call, so it is handed to the host's worker
|
|
79
|
+
# and the request answers at once; the result arrives as a notification
|
|
80
|
+
# linking back into the designer. A host with no such worker gets the
|
|
81
|
+
# old inline behaviour rather than an error.
|
|
82
|
+
if Labimotion::AiKlassQueue.queue(kind: 'segment', params: params, user: current_user,
|
|
83
|
+
element_klass_id: @klass&.id)
|
|
84
|
+
next { status: 'queued',
|
|
85
|
+
message: 'Generating the template in the background. You will be notified when it is ready.' }
|
|
86
|
+
end
|
|
87
|
+
|
|
72
88
|
msg = create_ai_segment_klass(current_user, params)
|
|
73
89
|
{ status: msg[:status], message: msg[:message],
|
|
74
90
|
klass: Labimotion::SegmentKlassEntity.represent(Labimotion::SegmentKlass.all) }
|
|
@@ -113,9 +113,24 @@ module Labimotion
|
|
|
113
113
|
'created_by' => current_user.id
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
|
|
116
|
+
# Same as the non-AI create beside it: the owner row shares the create's
|
|
117
|
+
# transaction, because a klass committed without one is owner-less and
|
|
118
|
+
# falls OPEN to the legacy designer-wide gate — every designer of the
|
|
119
|
+
# family could then edit, release and delete it, while its creator holds
|
|
120
|
+
# no special standing at all. seed_owner! rescues only RecordNotUnique and
|
|
121
|
+
# RecordInvalid, so any other failure has to take the klass down with it;
|
|
122
|
+
# this helper turns every StandardError into { status: 'error' }, and
|
|
123
|
+
# without the transaction the caller would be told the create failed while
|
|
124
|
+
# an unowned template silently persisted.
|
|
125
|
+
ds = Labimotion::DatasetKlass.transaction do
|
|
126
|
+
Labimotion::DatasetKlass.create!(attributes).tap do |klz|
|
|
127
|
+
Labimotion::KlassShare.seed_owner!(klz, current_user.id)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
117
130
|
ds.create_klasses_revision(current_user)
|
|
118
|
-
|
|
131
|
+
# `record` is the row itself. The background job that calls this needs the
|
|
132
|
+
# template, not a sentence about it, to link the notification back to it.
|
|
133
|
+
{ status: 'success', record: ds,
|
|
119
134
|
message: "The AI dataset template [#{label}] has been created as inactive. Review and activate it in the designer." }
|
|
120
135
|
rescue StandardError => e
|
|
121
136
|
Labimotion.log_exception(e, current_user)
|
|
@@ -144,18 +159,67 @@ module Labimotion
|
|
|
144
159
|
cols: params[:cols],
|
|
145
160
|
**overrides
|
|
146
161
|
)
|
|
147
|
-
|
|
148
|
-
status: 'success',
|
|
149
|
-
label: ai['label'].presence || params[:label],
|
|
150
|
-
layers: ai['layers'],
|
|
151
|
-
select_options: ai['select_options'],
|
|
152
|
-
summary: ai['summary']
|
|
153
|
-
}
|
|
162
|
+
refine_outcome(ai, params[:label])
|
|
154
163
|
rescue StandardError => e
|
|
155
164
|
Labimotion.log_exception(e, current_user)
|
|
156
165
|
{ status: 'error', message: e.message }
|
|
157
166
|
end
|
|
158
167
|
|
|
168
|
+
# Turn an instruction into a list of STRUCTURAL operations the designer applies
|
|
169
|
+
# itself, from an INDEX of the open template rather than the template. A layout
|
|
170
|
+
# change then costs what the sentence costs, not what the template costs.
|
|
171
|
+
#
|
|
172
|
+
# Four outcomes, three of them normal:
|
|
173
|
+
# 'success' — operations to apply
|
|
174
|
+
# 'design' — not expressible as operations; the client re-asks on refine
|
|
175
|
+
# 'unrelated' — not a template change at all; the turn STOPS here
|
|
176
|
+
# 'error' — the request failed
|
|
177
|
+
# 'design' is deliberately not an error: it is the routing answer for every
|
|
178
|
+
# instruction that needs new fields, wording, units or ontology terms.
|
|
179
|
+
# 'unrelated' exists so an off-topic message does not fall through to refine,
|
|
180
|
+
# where the whole template would be re-emitted to report that nothing changed.
|
|
181
|
+
def plan_ai_dataset_klass(params, current_user)
|
|
182
|
+
instruction = params[:instruction].to_s.strip
|
|
183
|
+
raise 'An instruction is required' if instruction.blank?
|
|
184
|
+
|
|
185
|
+
overrides = ai_user_overrides(current_user)
|
|
186
|
+
ai = Labimotion::AiTemplate.plan(
|
|
187
|
+
index: params[:index] || {},
|
|
188
|
+
instruction: instruction,
|
|
189
|
+
ols_term_id: ai_term_with_label(params[:ols_term_id]),
|
|
190
|
+
history: params[:history],
|
|
191
|
+
**overrides
|
|
192
|
+
)
|
|
193
|
+
plan_outcome(ai)
|
|
194
|
+
rescue StandardError => e
|
|
195
|
+
Labimotion.log_exception(e, current_user)
|
|
196
|
+
{ status: 'error', message: e.message }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def refine_outcome(refined, fallback_label)
|
|
200
|
+
{
|
|
201
|
+
status: 'success',
|
|
202
|
+
label: refined['label'].presence || fallback_label,
|
|
203
|
+
layers: refined['layers'],
|
|
204
|
+
select_options: refined['select_options'],
|
|
205
|
+
summary: refined['summary'],
|
|
206
|
+
usage: refined['usage'],
|
|
207
|
+
model: refined['model']
|
|
208
|
+
}
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Which of the three normal outcomes this plan is. `model` rides on all of
|
|
212
|
+
# them: it is what actually answered, which is not always what the user
|
|
213
|
+
# picked — a keyless user's choice is clamped to the server allowlist.
|
|
214
|
+
def plan_outcome(plan)
|
|
215
|
+
base = { reason: plan['reason'], usage: plan['usage'], model: plan['model'] }
|
|
216
|
+
return base.merge(status: 'unrelated') if plan['unrelated']
|
|
217
|
+
return base.merge(status: 'design') if plan['needs_design']
|
|
218
|
+
|
|
219
|
+
{ status: 'success', operations: plan['operations'], summary: plan['summary'],
|
|
220
|
+
usage: plan['usage'], model: plan['model'] }
|
|
221
|
+
end
|
|
222
|
+
|
|
159
223
|
def find_best_match_template(ols_term_id)
|
|
160
224
|
result = Labimotion::TemplateMatcher.find_best_match(ols_term_id)
|
|
161
225
|
if result[:template]
|
|
@@ -106,13 +106,28 @@ module Labimotion
|
|
|
106
106
|
'created_by' => current_user.id
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
|
|
109
|
+
# Same as the non-AI create beside it: the owner row shares the create's
|
|
110
|
+
# transaction, because a klass committed without one is owner-less and
|
|
111
|
+
# falls OPEN to the legacy designer-wide gate — every designer of the
|
|
112
|
+
# family could then edit, release and delete it, while its creator holds
|
|
113
|
+
# no special standing at all. seed_owner! rescues only RecordNotUnique and
|
|
114
|
+
# RecordInvalid, so any other failure has to take the klass down with it;
|
|
115
|
+
# this helper turns every StandardError into { status: 'error' }, and
|
|
116
|
+
# without the transaction the caller would be told the create failed while
|
|
117
|
+
# an unowned template silently persisted.
|
|
118
|
+
new_klass = Labimotion::ElementKlass.transaction do
|
|
119
|
+
Labimotion::ElementKlass.create!(attributes).tap do |klz|
|
|
120
|
+
Labimotion::KlassShare.seed_owner!(klz, current_user.id)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
110
123
|
new_klass.reload
|
|
111
124
|
new_klass.create_klasses_revision(current_user)
|
|
112
125
|
klass_names_file = Labimotion::KLASSES_JSON # Rails.root.join('app/packs/klasses.json')
|
|
113
126
|
klasses = Labimotion::ElementKlass.where(is_active: true)&.pluck(:name) || []
|
|
114
127
|
File.write(klass_names_file, klasses)
|
|
115
|
-
|
|
128
|
+
# `record` is the row itself. The background job that calls this needs the
|
|
129
|
+
# template, not a sentence about it, to link the notification back to it.
|
|
130
|
+
{ status: 'success', record: new_klass,
|
|
116
131
|
message: "The AI element template [#{label}] has been created as inactive. Review and activate it in the designer." }
|
|
117
132
|
rescue StandardError => e
|
|
118
133
|
Labimotion.log_exception(e, current_user)
|
|
@@ -141,7 +156,7 @@ module Labimotion
|
|
|
141
156
|
instructions: ai_fill_instructions(element),
|
|
142
157
|
**overrides
|
|
143
158
|
)
|
|
144
|
-
{ status: 'success', values: ai['values'], summary: ai['summary'] }
|
|
159
|
+
{ status: 'success', values: ai['values'], summary: ai['summary'], usage: ai['usage'], model: ai['model'] }
|
|
145
160
|
rescue StandardError => e
|
|
146
161
|
Labimotion.log_exception(e, current_user)
|
|
147
162
|
{ status: 'error', message: e.message }
|
|
@@ -104,6 +104,19 @@ module Labimotion
|
|
|
104
104
|
end
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
+
params :plan_ai_dataset_klass_params do
|
|
108
|
+
requires :instruction, type: String, desc: 'Natural-language change to apply to the template'
|
|
109
|
+
optional :ols_term_id, type: String, desc: 'CHMO ontology term (context only)'
|
|
110
|
+
# The INDEX, not the template: layer/field keys, labels and types only. Left
|
|
111
|
+
# as an opaque Hash because the gem only relays it — the client builds it and
|
|
112
|
+
# the client consumes the plan that comes back.
|
|
113
|
+
optional :index, type: Hash, desc: 'Compact index of the open template (keys, labels, types, groups)'
|
|
114
|
+
optional :history, type: Array, desc: 'Prior chat turns for continuity' do
|
|
115
|
+
optional :role, type: String, desc: 'user | assistant'
|
|
116
|
+
optional :content, type: String, desc: 'Message content'
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
107
120
|
params :update_element_klass_params do
|
|
108
121
|
requires :id, type: Integer, desc: 'Element Klass ID'
|
|
109
122
|
optional :label, type: String, desc: 'Element Klass Label'
|
|
@@ -106,10 +106,25 @@ module Labimotion
|
|
|
106
106
|
# params[:metadata] via declared. Absent -> leave the column default.
|
|
107
107
|
attributes['metadata'] = params[:metadata] if params[:metadata].present?
|
|
108
108
|
|
|
109
|
-
|
|
109
|
+
# Same as the non-AI create beside it: the owner row shares the create's
|
|
110
|
+
# transaction, because a klass committed without one is owner-less and
|
|
111
|
+
# falls OPEN to the legacy designer-wide gate — every designer of the
|
|
112
|
+
# family could then edit, release and delete it, while its creator holds
|
|
113
|
+
# no special standing at all. seed_owner! rescues only RecordNotUnique and
|
|
114
|
+
# RecordInvalid, so any other failure has to take the klass down with it;
|
|
115
|
+
# this helper turns every StandardError into { status: 'error' }, and
|
|
116
|
+
# without the transaction the caller would be told the create failed while
|
|
117
|
+
# an unowned template silently persisted.
|
|
118
|
+
klass = Labimotion::SegmentKlass.transaction do
|
|
119
|
+
Labimotion::SegmentKlass.create!(attributes).tap do |klz|
|
|
120
|
+
Labimotion::KlassShare.seed_owner!(klz, current_user.id)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
110
123
|
klass.reload
|
|
111
124
|
klass.create_klasses_revision(current_user)
|
|
112
|
-
|
|
125
|
+
# `record` is the row itself. The background job that calls this needs the
|
|
126
|
+
# template, not a sentence about it, to link the notification back to it.
|
|
127
|
+
{ status: 'success', record: klass,
|
|
113
128
|
message: "The AI segment template [#{params[:label]}] has been created as inactive. Review and activate it in the designer." }
|
|
114
129
|
rescue StandardError => e
|
|
115
130
|
Labimotion.log_exception(e, current_user)
|
|
@@ -26,10 +26,13 @@ module Labimotion
|
|
|
26
26
|
'255.255.255.255/32', '::/128', '::1/128', 'fc00::/7', 'fe80::/10', 'ff00::/8'
|
|
27
27
|
].map { |cidr| IPAddr.new(cidr) }.freeze
|
|
28
28
|
|
|
29
|
-
# Raise BlockedError unless url is a public https endpoint
|
|
29
|
+
# Raise BlockedError unless url is a public https endpoint (http is also
|
|
30
|
+
# accepted outside production, e.g. against a local dev provider). Returns url.
|
|
30
31
|
def self.validate!(url)
|
|
31
32
|
uri = parse(url)
|
|
32
|
-
|
|
33
|
+
allowed_schemes = Rails.env.production? ? %w[https] : %w[https http]
|
|
34
|
+
raise BlockedError, "AI provider URL must use #{allowed_schemes.join(' or ')}" unless
|
|
35
|
+
allowed_schemes.include?(uri.scheme)
|
|
33
36
|
|
|
34
37
|
host = uri.host.to_s
|
|
35
38
|
raise BlockedError, 'AI provider URL has no host' if host.empty?
|
|
@@ -37,7 +40,7 @@ module Labimotion
|
|
|
37
40
|
addresses = resolve(host)
|
|
38
41
|
raise BlockedError, "AI provider host could not be resolved: #{host}" if addresses.empty?
|
|
39
42
|
|
|
40
|
-
blocked = addresses.find { |ip| !
|
|
43
|
+
blocked = addresses.find { |ip| !allowed_ip?(ip) }
|
|
41
44
|
raise BlockedError, "AI provider host resolves to a non-public address (#{blocked})" if blocked
|
|
42
45
|
|
|
43
46
|
url
|
|
@@ -80,5 +83,17 @@ module Labimotion
|
|
|
80
83
|
rescue StandardError
|
|
81
84
|
false
|
|
82
85
|
end
|
|
86
|
+
|
|
87
|
+
# Outside production, a private-network address (e.g. a self-hosted model
|
|
88
|
+
# on another machine on the LAN) is allowed; loopback/link-local/other
|
|
89
|
+
# EXTRA_BLOCKED ranges (cloud metadata etc.) stay blocked regardless of env.
|
|
90
|
+
def self.allowed_ip?(ip)
|
|
91
|
+
return public_ip?(ip) if Rails.env.production?
|
|
92
|
+
return false if ip.loopback? || ip.link_local?
|
|
93
|
+
|
|
94
|
+
EXTRA_BLOCKED.none? { |net| net.include?(ip) }
|
|
95
|
+
rescue StandardError
|
|
96
|
+
false
|
|
97
|
+
end
|
|
83
98
|
end
|
|
84
99
|
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Labimotion
|
|
6
|
+
# Hands AI template generation to the host's background worker.
|
|
7
|
+
#
|
|
8
|
+
# Generating a template is one long provider call — 18s for a small segment,
|
|
9
|
+
# past 90s for a large one — and running it inside the request held the dialog
|
|
10
|
+
# open for all of it, losing the work if the tab was closed or reloaded. The
|
|
11
|
+
# endpoints enqueue instead and answer at once; the result comes back as a
|
|
12
|
+
# notification carrying a link into the designer.
|
|
13
|
+
#
|
|
14
|
+
# The job itself belongs to the host: ActiveJob, the queue and the Message
|
|
15
|
+
# machinery are all its, and a gem that shipped its own would be defining
|
|
16
|
+
# infrastructure it does not own. So the host class is reached by its bare
|
|
17
|
+
# name and only if it exists — the same NameError-guarded pattern used for
|
|
18
|
+
# Matrice and Message elsewhere in this gem. A host without it gets the old
|
|
19
|
+
# inline behaviour rather than an error, which is what `queue` returning false
|
|
20
|
+
# tells the caller.
|
|
21
|
+
module AiKlassQueue
|
|
22
|
+
JOB = 'AiTemplateJob'
|
|
23
|
+
|
|
24
|
+
# @return [Boolean] true when the work was handed off
|
|
25
|
+
def self.queue(kind:, params:, user:, element_klass_id: nil)
|
|
26
|
+
job = job_class
|
|
27
|
+
return false if job.nil?
|
|
28
|
+
|
|
29
|
+
# Grape params are a Hashie::Mash; the queue serialises what it is given,
|
|
30
|
+
# and a Mash carries method_missing behaviour a worker process should not
|
|
31
|
+
# be asked to rebuild. A plain hash is the whole contract.
|
|
32
|
+
args = [kind.to_s, plain(params), user.id, element_klass_id]
|
|
33
|
+
inline? ? run_now(job, args, user) : job.perform_later(*args)
|
|
34
|
+
true
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
# An enqueue that fails must not fail the request: the caller falls back
|
|
37
|
+
# to generating inline, which is slower but still correct.
|
|
38
|
+
Labimotion.log_exception(e, user)
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Queued in every environment, development included: one code path, and the
|
|
43
|
+
# request returns straight away wherever it runs. Development therefore
|
|
44
|
+
# needs a worker like anywhere else — without one the job sits in the table
|
|
45
|
+
# and the notification never arrives.
|
|
46
|
+
#
|
|
47
|
+
# LABIMOTION_AI_INLINE=true runs it in the request instead, for a machine
|
|
48
|
+
# with no worker to spare. Off unless asked for, because a create that
|
|
49
|
+
# blocks for the length of a provider call is not the behaviour anyone
|
|
50
|
+
# should get by accident.
|
|
51
|
+
def self.inline?
|
|
52
|
+
ENV['LABIMOTION_AI_INLINE'] == 'true'
|
|
53
|
+
rescue StandardError
|
|
54
|
+
false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Deliberately swallows: the job reports its own failure as a notification,
|
|
58
|
+
# exactly as it would on a worker, and letting it raise here would send the
|
|
59
|
+
# caller down the inline-generation fallback — spending a second provider
|
|
60
|
+
# call on work that has already been done and paid for.
|
|
61
|
+
def self.run_now(job, args, user)
|
|
62
|
+
job.perform_now(*args)
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
Labimotion.log_exception(e, user)
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.job_class
|
|
69
|
+
Object.const_get(JOB)
|
|
70
|
+
rescue NameError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Only what the helpers read, and stringified: file payloads included, since
|
|
75
|
+
# the reference files are part of the request the worker has to replay.
|
|
76
|
+
#
|
|
77
|
+
# A JSON round trip rather than as_json, because that is precisely what the
|
|
78
|
+
# queue will do to these arguments anyway — so anything that would not
|
|
79
|
+
# survive being written to the jobs table fails here, in the request, rather
|
|
80
|
+
# than in a worker nobody is watching.
|
|
81
|
+
def self.plain(params)
|
|
82
|
+
return {} if params.nil?
|
|
83
|
+
|
|
84
|
+
source = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params
|
|
85
|
+
JSON.parse(JSON.generate(source))
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Labimotion
|
|
4
|
+
# The checks that must happen BEFORE a template is generated.
|
|
5
|
+
#
|
|
6
|
+
# Generation moved to a background job, and the cheap objections — this name
|
|
7
|
+
# is taken, this ontology term already has a template — used to live inside
|
|
8
|
+
# the create helpers, which now run inside that job. So the user spent a
|
|
9
|
+
# provider call and 15-90s of waiting only to be told the label was already in
|
|
10
|
+
# use, which was knowable before a single token was spent. Worse, the answer
|
|
11
|
+
# arrived as a notification long after the dialog that could have fixed it had
|
|
12
|
+
# closed.
|
|
13
|
+
#
|
|
14
|
+
# These are only the objections that can be settled by a lookup. Anything the
|
|
15
|
+
# model decides at create! stays there: this is a courtesy gate, not a second
|
|
16
|
+
# source of truth, and the create still validates for real.
|
|
17
|
+
module AiKlassValidator
|
|
18
|
+
# @return [String, nil] the objection, or nil when there is none
|
|
19
|
+
def self.error_for(kind:, params:, element_klass: nil)
|
|
20
|
+
files = file_error(params)
|
|
21
|
+
return files if files
|
|
22
|
+
|
|
23
|
+
case kind.to_s
|
|
24
|
+
when 'element' then element_error(params)
|
|
25
|
+
when 'segment' then segment_error(params, element_klass)
|
|
26
|
+
when 'dataset' then dataset_error(params)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.file_error(params)
|
|
31
|
+
count = Array(params[:files]).size
|
|
32
|
+
return nil if count <= Labimotion::AiTemplate::MAX_FILES
|
|
33
|
+
|
|
34
|
+
"Too many files (max #{Labimotion::AiTemplate::MAX_FILES})."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Mirrors ElementKlass's `validates :name, uniqueness:` — scoped to rows that
|
|
38
|
+
# are not soft-deleted, so a deleted template does not block the name it no
|
|
39
|
+
# longer holds.
|
|
40
|
+
def self.element_error(params)
|
|
41
|
+
name = params[:name].to_s.strip
|
|
42
|
+
return 'A name is required.' if name.blank?
|
|
43
|
+
return 'A label is required.' if params[:label].to_s.strip.blank?
|
|
44
|
+
|
|
45
|
+
return unless Labimotion::ElementKlass.where(deleted_at: nil).exists?(name: name)
|
|
46
|
+
|
|
47
|
+
"An element named [#{name}] already exists."
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Mirrors SegmentKlass's `validates :label, uniqueness: { scope: :element_klass_id }`.
|
|
51
|
+
# The label only has to be unique WITHIN its parent element, which is why the
|
|
52
|
+
# parent has to be known before this can be answered at all.
|
|
53
|
+
def self.segment_error(params, element_klass)
|
|
54
|
+
label = params[:label].to_s.strip
|
|
55
|
+
return 'A label is required.' if label.blank?
|
|
56
|
+
return 'A parent element is required.' if element_klass.nil?
|
|
57
|
+
|
|
58
|
+
taken = Labimotion::SegmentKlass.where(deleted_at: nil)
|
|
59
|
+
.exists?(label: label, element_klass_id: element_klass.id)
|
|
60
|
+
return unless taken
|
|
61
|
+
|
|
62
|
+
"A segment template labelled [#{label}] already exists for this element."
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.dataset_error(params)
|
|
66
|
+
term = params[:ols_term_id].to_s.split('|').first.to_s.strip
|
|
67
|
+
return 'An ontology term (CHMO) is required.' if term.blank?
|
|
68
|
+
|
|
69
|
+
return unless Labimotion::DatasetKlass.where(deleted_at: nil).exists?(ols_term_id: term)
|
|
70
|
+
|
|
71
|
+
"A dataset template already exists for #{term}. Edit it in the designer instead."
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -72,6 +72,45 @@ module Labimotion
|
|
|
72
72
|
# while still bounding a template that puts an essay on every field.
|
|
73
73
|
MAX_FIELD_DESC_CHARS = 1000
|
|
74
74
|
|
|
75
|
+
# A structural plan is a handful of operations, never a template. Capping the
|
|
76
|
+
# reply this hard is the whole point of the path: it bounds the cheap route to
|
|
77
|
+
# a rounding error, and a model that starts echoing a template instead of
|
|
78
|
+
# planning hits the ceiling and is escalated rather than billed for 16k tokens.
|
|
79
|
+
PLAN_MAX_TOKENS = 1200
|
|
80
|
+
|
|
81
|
+
# The closed vocabulary of STRUCTURAL operations the designer already applies
|
|
82
|
+
# itself (chem-generic-ui: action-handler / group-handler / sorting-handler),
|
|
83
|
+
# each mapped to the builder that validates it. Anything outside this table is
|
|
84
|
+
# design work and goes back through refine. A table rather than a case: the
|
|
85
|
+
# vocabulary is data, and the client dispatches on exactly these strings.
|
|
86
|
+
PLAN_BUILDERS = {
|
|
87
|
+
'delete_layer' => :delete_layer_op,
|
|
88
|
+
'ungroup_layer' => :ungroup_layer_op,
|
|
89
|
+
'update_layer' => :update_layer_op,
|
|
90
|
+
'delete_field' => :delete_field_op,
|
|
91
|
+
'set_field' => :field_update_op,
|
|
92
|
+
'reorder_layers' => :reorder_layers_op,
|
|
93
|
+
'group_layers' => :group_layers_op
|
|
94
|
+
}.freeze
|
|
95
|
+
|
|
96
|
+
# Layer attributes update_layer may change.
|
|
97
|
+
PLAN_LAYER_ATTRS = %w[label cols color].freeze
|
|
98
|
+
|
|
99
|
+
# Field attributes set_field may change. Deliberately EXCLUDES "type": a type
|
|
100
|
+
# change cascades into units, numeric config, restrictions and display-name
|
|
101
|
+
# references, and the designer's own handler owns that logic — routing it
|
|
102
|
+
# through here would reimplement it worse.
|
|
103
|
+
PLAN_FIELD_ATTRS = %w[label description placeholder required readonly cols hasOwnRow].freeze
|
|
104
|
+
|
|
105
|
+
# Field attributes above that are flags, not text.
|
|
106
|
+
PLAN_FIELD_FLAGS = %w[required readonly hasOwnRow].freeze
|
|
107
|
+
|
|
108
|
+
# A plan longer than this is not a structural edit any more.
|
|
109
|
+
MAX_PLAN_OPERATIONS = 25
|
|
110
|
+
|
|
111
|
+
# How much of a provider's error message is shown to the user.
|
|
112
|
+
MAX_PROVIDER_ERROR_CHARS = 300
|
|
113
|
+
|
|
75
114
|
# Generate a metadata template for a generic dataset, element or segment.
|
|
76
115
|
#
|
|
77
116
|
# The JSON template schema (layers / fields / select_options) is IDENTICAL
|
|
@@ -115,6 +154,33 @@ module Labimotion
|
|
|
115
154
|
.refine(current: current, instruction: instruction)
|
|
116
155
|
end
|
|
117
156
|
|
|
157
|
+
# Turn a natural-language instruction into a list of STRUCTURAL operations,
|
|
158
|
+
# without sending the template or asking the model to re-emit it.
|
|
159
|
+
#
|
|
160
|
+
# Refine's cost is set by the template, not the request: the model is told to
|
|
161
|
+
# reproduce every layer and field verbatim, so "delete the processing layer"
|
|
162
|
+
# bills the same thousands of output tokens as a redesign — and on a long
|
|
163
|
+
# template the model stops mid-copy, which surfaces as an unparseable
|
|
164
|
+
# response. Neither is inherent to the request. Deleting a layer, grouping
|
|
165
|
+
# two, reordering, flipping `required` are all things chem-generic-ui already
|
|
166
|
+
# does deterministically, cascades and workflow guard included; the model is
|
|
167
|
+
# only needed to read the sentence and name the layer.
|
|
168
|
+
#
|
|
169
|
+
# So this path sends an INDEX (keys, labels, types) and takes back a short op
|
|
170
|
+
# list the designer applies itself. When the instruction needs judgement the
|
|
171
|
+
# ops cannot express — new fields, wording, units, ontology terms — the model
|
|
172
|
+
# says so and the caller falls back to refine.
|
|
173
|
+
#
|
|
174
|
+
# @param index [Hash] compact template index (see chem-generic-ui buildTemplateIndex)
|
|
175
|
+
# @param instruction [String] the change to apply
|
|
176
|
+
# @param history [Array<Hash>] prior turns [{ 'role' => .., 'content' => .. }]
|
|
177
|
+
# @return [Hash] { 'operations' => Array, 'summary' => String,
|
|
178
|
+
# 'needs_design' => Boolean, 'reason' => String, 'usage' => Hash }
|
|
179
|
+
def self.plan(index:, instruction:, ols_term_id: nil, history: [], model: nil, api_key: nil, base_url: nil, api_path: nil)
|
|
180
|
+
new(ols_term_id: ols_term_id, history: history, model: model, api_key: api_key, base_url: base_url, api_path: api_path)
|
|
181
|
+
.plan(index: index, instruction: instruction)
|
|
182
|
+
end
|
|
183
|
+
|
|
118
184
|
# Extract DATA VALUES for an existing generic element/segment/dataset instance
|
|
119
185
|
# from a document's text, to pre-fill the working copy for human review. This
|
|
120
186
|
# does NOT design or modify a template — it only reads values for the fields the
|
|
@@ -229,7 +295,7 @@ module Labimotion
|
|
|
229
295
|
end
|
|
230
296
|
|
|
231
297
|
response = post_messages
|
|
232
|
-
|
|
298
|
+
request_failed!('generate', response) unless response.code == 200
|
|
233
299
|
|
|
234
300
|
body = JSON.parse(response.body)
|
|
235
301
|
guard_finish_reason!(body)
|
|
@@ -237,7 +303,7 @@ module Labimotion
|
|
|
237
303
|
text = extract_text(body)
|
|
238
304
|
raise 'AI returned an empty response' if text.blank?
|
|
239
305
|
|
|
240
|
-
normalize(parse_json(text))
|
|
306
|
+
normalize(parse_json(text)).merge('usage' => usage_from(body))
|
|
241
307
|
rescue JSON::ParserError => e
|
|
242
308
|
log_ai_response('generate could not parse response', response&.body)
|
|
243
309
|
Labimotion.log_exception(e)
|
|
@@ -249,7 +315,7 @@ module Labimotion
|
|
|
249
315
|
raise 'An instruction is required' if instruction.to_s.strip.blank?
|
|
250
316
|
|
|
251
317
|
response = post_chat(refine_messages(current, instruction))
|
|
252
|
-
|
|
318
|
+
request_failed!('refine', response) unless response.code == 200
|
|
253
319
|
|
|
254
320
|
body = JSON.parse(response.body)
|
|
255
321
|
guard_finish_reason!(body)
|
|
@@ -268,6 +334,8 @@ module Labimotion
|
|
|
268
334
|
end
|
|
269
335
|
|
|
270
336
|
result['summary'] = (data.is_a?(Hash) ? data['summary'].to_s.strip : '')
|
|
337
|
+
result['usage'] = usage_from(body)
|
|
338
|
+
result['model'] = model
|
|
271
339
|
result
|
|
272
340
|
rescue JSON::ParserError => e
|
|
273
341
|
log_ai_response('refine could not parse response', response&.body)
|
|
@@ -275,6 +343,25 @@ module Labimotion
|
|
|
275
343
|
raise 'AI returned a response that could not be parsed as a template'
|
|
276
344
|
end
|
|
277
345
|
|
|
346
|
+
def plan(index:, instruction:)
|
|
347
|
+
raise 'AI API key is not configured (set config/labimotion_ai.yml :api_key or KI_TOOLBOX_API_KEY)' if api_key.blank?
|
|
348
|
+
raise 'An instruction is required' if instruction.to_s.strip.blank?
|
|
349
|
+
|
|
350
|
+
response = post_chat(plan_messages(index, instruction), PLAN_MAX_TOKENS)
|
|
351
|
+
request_failed!('plan', response) unless response.code == 200
|
|
352
|
+
|
|
353
|
+
body = JSON.parse(response.body)
|
|
354
|
+
raise 'AI request was declined by the content filter' if finish_reason(body) == 'content_filter'
|
|
355
|
+
|
|
356
|
+
plan_from(body)
|
|
357
|
+
rescue JSON::ParserError => e
|
|
358
|
+
log_ai_response('plan could not parse response', response&.body)
|
|
359
|
+
Labimotion.log_exception(e)
|
|
360
|
+
# An unparseable PLAN is not a failed request — it is a signal to take the
|
|
361
|
+
# slower path, which is exactly what escalation asks the caller to do.
|
|
362
|
+
escalate('The model did not return a usable plan.')
|
|
363
|
+
end
|
|
364
|
+
|
|
278
365
|
def fill(properties:, context_text:, instructions: nil)
|
|
279
366
|
raise 'AI API key is not configured (set config/labimotion_ai.yml :api_key or KI_TOOLBOX_API_KEY)' if api_key.blank?
|
|
280
367
|
raise 'No readable text could be extracted from the selected source' if context_text.to_s.strip.blank?
|
|
@@ -288,7 +375,7 @@ module Labimotion
|
|
|
288
375
|
raise 'This element template has no fields to fill' if schema.empty?
|
|
289
376
|
|
|
290
377
|
response = post_chat(fill_messages(schema, context_text))
|
|
291
|
-
|
|
378
|
+
request_failed!('fill', response) unless response.code == 200
|
|
292
379
|
|
|
293
380
|
body = JSON.parse(response.body)
|
|
294
381
|
guard_finish_reason!(body)
|
|
@@ -300,7 +387,9 @@ module Labimotion
|
|
|
300
387
|
raw_values = data.is_a?(Hash) ? data['values'] : nil
|
|
301
388
|
{
|
|
302
389
|
'values' => normalize_fill_values(props, raw_values),
|
|
303
|
-
'summary' => (data.is_a?(Hash) ? data['summary'].to_s.strip : '')
|
|
390
|
+
'summary' => (data.is_a?(Hash) ? data['summary'].to_s.strip : ''),
|
|
391
|
+
'usage' => usage_from(body),
|
|
392
|
+
'model' => model
|
|
304
393
|
}
|
|
305
394
|
rescue JSON::ParserError => e
|
|
306
395
|
log_ai_response('fill could not parse response', response&.body)
|
|
@@ -325,7 +414,7 @@ module Labimotion
|
|
|
325
414
|
# content-filter block, or a response truncated at the token limit — the latter
|
|
326
415
|
# would otherwise surface as a confusing parse/empty-template error.
|
|
327
416
|
def guard_finish_reason!(body)
|
|
328
|
-
case
|
|
417
|
+
case finish_reason(body)
|
|
329
418
|
when 'content_filter'
|
|
330
419
|
raise 'AI request was declined by the content filter'
|
|
331
420
|
when 'length'
|
|
@@ -334,16 +423,59 @@ module Labimotion
|
|
|
334
423
|
end
|
|
335
424
|
end
|
|
336
425
|
|
|
426
|
+
def finish_reason(body)
|
|
427
|
+
Array(body['choices']).first&.dig('finish_reason').to_s
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# A non-200 carries the provider's own reason, and that reason is the only
|
|
431
|
+
# thing separating a 400 for "no such model" from a 400 for "context length
|
|
432
|
+
# exceeded" or "max_tokens above this model's limit". Raising the bare status
|
|
433
|
+
# code — as this used to — leaves whoever hit it on another instance with
|
|
434
|
+
# nothing to go on but the number.
|
|
435
|
+
# The whole body still reaches log/labimotion.log; only what the user is
|
|
436
|
+
# shown is capped, because a misconfigured endpoint can answer with an entire
|
|
437
|
+
# HTML error page.
|
|
438
|
+
def request_failed!(context, response)
|
|
439
|
+
log_ai_response("#{context} failed (HTTP #{response.code})", response.body)
|
|
440
|
+
detail = provider_error(response).to_s.strip[0, MAX_PROVIDER_ERROR_CHARS]
|
|
441
|
+
message = "AI request failed (HTTP #{response.code})"
|
|
442
|
+
message += " — #{detail}" if detail.present?
|
|
443
|
+
raise message
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# OpenAI-compatible gateways answer {"error": {"message": ..}}; this one
|
|
447
|
+
# (Open WebUI in front of vLLM) answers {"detail": ..}. Take either, and the
|
|
448
|
+
# raw body when it is neither.
|
|
449
|
+
def provider_error(response)
|
|
450
|
+
body = JSON.parse(response.body.to_s)
|
|
451
|
+
return '' unless body.is_a?(Hash)
|
|
452
|
+
|
|
453
|
+
err = body['error']
|
|
454
|
+
message = err.is_a?(Hash) ? err['message'] : err
|
|
455
|
+
message.presence || body['detail'].presence || body['message'].presence || ''
|
|
456
|
+
rescue JSON::ParserError
|
|
457
|
+
response.body.to_s.strip
|
|
458
|
+
end
|
|
459
|
+
|
|
337
460
|
# Best-effort: record the raw model output (truncated) to log/labimotion.log so a
|
|
338
461
|
# response that can't be turned into a template can be diagnosed. Never raises.
|
|
339
462
|
def log_ai_response(context, raw)
|
|
340
463
|
return if raw.to_s.strip.empty?
|
|
341
464
|
|
|
342
|
-
Labimotion.logger.warn("AiTemplate #{context}; raw (truncated): #{raw
|
|
465
|
+
Labimotion.logger.warn("AiTemplate #{context}; raw (truncated): #{loggable(raw)}")
|
|
343
466
|
rescue StandardError
|
|
344
467
|
nil
|
|
345
468
|
end
|
|
346
469
|
|
|
470
|
+
# An HTTParty body arrives as ASCII-8BIT, and a template full of °C and µm
|
|
471
|
+
# carries the high bytes to prove it. Interpolating that into a UTF-8 log
|
|
472
|
+
# line makes the write fail — silently, and precisely on the responses this
|
|
473
|
+
# exists to diagnose. Scrubbed to the encoding the log is actually in, and
|
|
474
|
+
# cut afterwards so the slice cannot split a character in half.
|
|
475
|
+
def loggable(raw)
|
|
476
|
+
raw.to_s.dup.force_encoding('UTF-8').scrub('')[0, 2000]
|
|
477
|
+
end
|
|
478
|
+
|
|
347
479
|
# Map a non-200 status from the ping into a message that points at the setting
|
|
348
480
|
# most likely at fault. Network/SSRF failures raise before this (from post_chat).
|
|
349
481
|
def ping_error(code)
|
|
@@ -452,6 +584,24 @@ module Labimotion
|
|
|
452
584
|
'model in your AI settings, or raise :timeout in config/labimotion_ai.yml.'
|
|
453
585
|
end
|
|
454
586
|
|
|
587
|
+
# What the call cost, as the provider counted it. OpenAI-compatible responses
|
|
588
|
+
# carry it in `usage`; KI-Toolbox does, and it is the only per-request figure
|
|
589
|
+
# available — the gateway exposes no account-level usage API.
|
|
590
|
+
#
|
|
591
|
+
# nil when the provider reports nothing, rather than zeros: "not reported" and
|
|
592
|
+
# "cost nothing" are different answers and a caller should be able to tell
|
|
593
|
+
# them apart before showing a number to a user.
|
|
594
|
+
def usage_from(body)
|
|
595
|
+
usage = body['usage']
|
|
596
|
+
return nil unless usage.is_a?(Hash)
|
|
597
|
+
|
|
598
|
+
{
|
|
599
|
+
'prompt' => usage['prompt_tokens'],
|
|
600
|
+
'completion' => usage['completion_tokens'],
|
|
601
|
+
'total' => usage['total_tokens']
|
|
602
|
+
}.compact.presence
|
|
603
|
+
end
|
|
604
|
+
|
|
455
605
|
# OpenAI-compatible chat completions return the text at
|
|
456
606
|
# choices[0].message.content.
|
|
457
607
|
def extract_text(body)
|
|
@@ -726,13 +876,7 @@ module Labimotion
|
|
|
726
876
|
def refine_messages(current, instruction)
|
|
727
877
|
messages = [{ role: 'system', content: refine_system_content(current) }]
|
|
728
878
|
messages << { role: 'user', content: current_template_context(current) }
|
|
729
|
-
|
|
730
|
-
role = (turn['role'] || turn[:role]).to_s
|
|
731
|
-
content = (turn['content'] || turn[:content]).to_s
|
|
732
|
-
next if content.strip.blank?
|
|
733
|
-
|
|
734
|
-
messages << { role: (role == 'assistant' ? 'assistant' : 'user'), content: content }
|
|
735
|
-
end
|
|
879
|
+
messages.concat(history_messages)
|
|
736
880
|
messages << { role: 'user', content: refine_instruction_prompt(instruction) }
|
|
737
881
|
messages
|
|
738
882
|
end
|
|
@@ -923,6 +1067,257 @@ module Labimotion
|
|
|
923
1067
|
PROMPT
|
|
924
1068
|
end
|
|
925
1069
|
|
|
1070
|
+
# --- structural plan ----------------------------------------------------
|
|
1071
|
+
|
|
1072
|
+
def plan_messages(index, instruction)
|
|
1073
|
+
messages = [{ role: 'system', content: plan_system_content }]
|
|
1074
|
+
messages << { role: 'user', content: plan_index_context(index) }
|
|
1075
|
+
messages.concat(history_messages)
|
|
1076
|
+
messages << { role: 'user', content: plan_instruction_prompt(instruction) }
|
|
1077
|
+
messages
|
|
1078
|
+
end
|
|
1079
|
+
|
|
1080
|
+
def plan_system_content
|
|
1081
|
+
cfg[:plan_system_prompt].presence || default_plan_system_prompt
|
|
1082
|
+
end
|
|
1083
|
+
|
|
1084
|
+
# Written against the operations chem-generic-ui can already apply. Every rule
|
|
1085
|
+
# here is about NAMING an existing thing, never about describing one — which
|
|
1086
|
+
# is why this prompt is a fraction of refine's and why its reply is bounded:
|
|
1087
|
+
# there is nothing in the vocabulary whose length grows with the template.
|
|
1088
|
+
def default_plan_system_prompt
|
|
1089
|
+
<<~PROMPT
|
|
1090
|
+
You turn a change request for a LabIMotion metadata template into a
|
|
1091
|
+
STRUCTURAL PLAN: a short list of operations the designer applies itself.
|
|
1092
|
+
You never write templates, layers or fields.
|
|
1093
|
+
|
|
1094
|
+
You are given an INDEX of the open template — its layers (key, label,
|
|
1095
|
+
columns, group) and, per layer, its fields (key, label, type). It omits
|
|
1096
|
+
descriptions, options, units and restrictions.
|
|
1097
|
+
|
|
1098
|
+
Reply with a SINGLE JSON object — no prose, no markdown, no code fences —
|
|
1099
|
+
in one of exactly three shapes:
|
|
1100
|
+
|
|
1101
|
+
A { "operations": [ ... ], "summary": "one sentence describing the change" }
|
|
1102
|
+
The operations below can carry the request out.
|
|
1103
|
+
|
|
1104
|
+
B { "needs_design": true, "reason": "<short reason>" }
|
|
1105
|
+
It is about this template, but the operations cannot express it
|
|
1106
|
+
(adding a field or layer, wording, types, units, options, ontology
|
|
1107
|
+
terms, moving a field between layers, merging layers, renaming a
|
|
1108
|
+
key), or you cannot tell which layer or field it means, or it is only
|
|
1109
|
+
partly structural — a partial plan would silently drop the rest.
|
|
1110
|
+
|
|
1111
|
+
C { "unrelated": true, "reason": "<one sentence, what this dialog is for>" }
|
|
1112
|
+
The message asks for NO change to anything — a greeting, a question
|
|
1113
|
+
about the world, arithmetic, chit-chat. Do not answer the message.
|
|
1114
|
+
|
|
1115
|
+
C is decided by what the message ASKS FOR, never by what you can do with
|
|
1116
|
+
it. Any request to change the template — however vague, however badly it
|
|
1117
|
+
names things, "tidy this up" included — is A or B, never C.
|
|
1118
|
+
|
|
1119
|
+
"delete the results layer" -> A
|
|
1120
|
+
"make the operator field required" -> A
|
|
1121
|
+
"put setup and acquisition in one group" -> A
|
|
1122
|
+
"add a field for the serial number" -> B
|
|
1123
|
+
"tidy this up" -> B
|
|
1124
|
+
"remove the physical description bit" -> A if a layer matches, else B
|
|
1125
|
+
"1+1=?" -> C
|
|
1126
|
+
"hello" -> C
|
|
1127
|
+
"what is the boiling point of water?" -> C
|
|
1128
|
+
|
|
1129
|
+
Operations, and nothing else:
|
|
1130
|
+
- {"op":"delete_layer","layer":"<layer_key>"}
|
|
1131
|
+
- {"op":"update_layer","layer":"<layer_key>","label":"..","cols":1-6,"color":"default"}
|
|
1132
|
+
- {"op":"delete_field","layer":"<layer_key>","field":"<field_key>"}
|
|
1133
|
+
- {"op":"set_field","layer":"<layer_key>","field":"<field_key>","required":true,
|
|
1134
|
+
"readonly":false,"label":"..","description":"..","placeholder":"..","cols":1-6,"hasOwnRow":true}
|
|
1135
|
+
- {"op":"reorder_layers","order":["<layer_key>", ...]}
|
|
1136
|
+
- {"op":"group_layers","label":"<group label>","layers":["<layer_key>", ...]}
|
|
1137
|
+
- {"op":"ungroup_layer","layer":"<layer_key>"}
|
|
1138
|
+
|
|
1139
|
+
- Match what a request names against the index by label, ignoring case,
|
|
1140
|
+
plurals and small wording differences; take the closest match. A name
|
|
1141
|
+
that matches nothing is B, never C.
|
|
1142
|
+
- Use the index's exact layer_key and field_key strings; never invent one.
|
|
1143
|
+
- Send only the attributes the request asks you to change; never pad.
|
|
1144
|
+
- set_field CHANGES a field already in the index and can never add one.
|
|
1145
|
+
A request to ADD a field or layer is B, even when something similar
|
|
1146
|
+
is already there.
|
|
1147
|
+
- group_layers needs only its "label": free text you choose, no id.
|
|
1148
|
+
Grouping existing layers is always A, never B.
|
|
1149
|
+
- reorder_layers takes EVERY layer key, in the new order.
|
|
1150
|
+
PROMPT
|
|
1151
|
+
end
|
|
1152
|
+
|
|
1153
|
+
def plan_index_context(index)
|
|
1154
|
+
index = {} unless index.is_a?(Hash)
|
|
1155
|
+
"Template index (JSON):\n#{JSON.generate(index)}"
|
|
1156
|
+
end
|
|
1157
|
+
|
|
1158
|
+
def plan_instruction_prompt(instruction)
|
|
1159
|
+
<<~PROMPT.strip
|
|
1160
|
+
Change request: #{instruction}
|
|
1161
|
+
|
|
1162
|
+
Answer with the structural plan for this request, with needs_design if it
|
|
1163
|
+
cannot be expressed by the operations above, or with unrelated if it is
|
|
1164
|
+
not a request to change this template.
|
|
1165
|
+
PROMPT
|
|
1166
|
+
end
|
|
1167
|
+
|
|
1168
|
+
# Prior turns, shared by refine and plan. Both need the same continuity and
|
|
1169
|
+
# the same coercion of an untrusted role to one of the two the API accepts.
|
|
1170
|
+
def history_messages
|
|
1171
|
+
@history.filter_map do |turn|
|
|
1172
|
+
content = (turn['content'] || turn[:content]).to_s
|
|
1173
|
+
next if content.strip.blank?
|
|
1174
|
+
|
|
1175
|
+
role = (turn['role'] || turn[:role]).to_s
|
|
1176
|
+
{ role: (role == 'assistant' ? 'assistant' : 'user'), content: content }
|
|
1177
|
+
end
|
|
1178
|
+
end
|
|
1179
|
+
|
|
1180
|
+
def plan_from(body)
|
|
1181
|
+
usage = usage_from(body)
|
|
1182
|
+
# A plan that hits the ceiling is a model writing a template instead of
|
|
1183
|
+
# planning. Escalate rather than raise — refine can still serve this one.
|
|
1184
|
+
return escalate('The model returned a template instead of a plan.', usage) if finish_reason(body) == 'length'
|
|
1185
|
+
|
|
1186
|
+
text = extract_text(body)
|
|
1187
|
+
return escalate('AI returned an empty response.', usage) if text.blank?
|
|
1188
|
+
|
|
1189
|
+
data = parse_json(text)
|
|
1190
|
+
data = {} unless data.is_a?(Hash)
|
|
1191
|
+
return unrelated(data['reason'].to_s.strip, usage) if truthy?(data['unrelated'])
|
|
1192
|
+
return escalate(data['reason'].to_s.strip, usage) if truthy?(data['needs_design'])
|
|
1193
|
+
|
|
1194
|
+
operations = normalize_operations(data['operations'])
|
|
1195
|
+
return escalate('The request did not map to any structural operation.', usage) if operations.empty?
|
|
1196
|
+
|
|
1197
|
+
plan_result(usage).merge('operations' => operations, 'summary' => data['summary'].to_s.strip)
|
|
1198
|
+
end
|
|
1199
|
+
|
|
1200
|
+
# "Not structural" is a routing answer, not a failure: the caller re-asks on
|
|
1201
|
+
# the refine path. usage still rides along — the attempt was billed.
|
|
1202
|
+
def escalate(reason, usage = nil)
|
|
1203
|
+
plan_result(usage).merge(
|
|
1204
|
+
'needs_design' => true,
|
|
1205
|
+
'reason' => reason.presence || 'This request needs template design.'
|
|
1206
|
+
)
|
|
1207
|
+
end
|
|
1208
|
+
|
|
1209
|
+
# Neither structural nor design: the message was not a change to this
|
|
1210
|
+
# template at all. Escalating it would send the whole template to be re-emitted
|
|
1211
|
+
# verbatim so the model can reply that there was nothing to do — thousands of
|
|
1212
|
+
# output tokens for an answer the plan call already has. So this outcome stops
|
|
1213
|
+
# the turn instead of falling through to refine.
|
|
1214
|
+
def unrelated(reason, usage = nil)
|
|
1215
|
+
plan_result(usage).merge(
|
|
1216
|
+
'unrelated' => true,
|
|
1217
|
+
'reason' => reason.presence || 'That is not a change to this template.'
|
|
1218
|
+
)
|
|
1219
|
+
end
|
|
1220
|
+
|
|
1221
|
+
# `model` is what actually served the request, not what the user picked: a
|
|
1222
|
+
# keyless user's choice is clamped to the server allowlist, so the two can
|
|
1223
|
+
# differ and only this one is the answer to "which model ran?".
|
|
1224
|
+
def plan_result(usage)
|
|
1225
|
+
{
|
|
1226
|
+
'operations' => [], 'summary' => '', 'needs_design' => false,
|
|
1227
|
+
'unrelated' => false, 'usage' => usage, 'model' => model
|
|
1228
|
+
}
|
|
1229
|
+
end
|
|
1230
|
+
|
|
1231
|
+
# Drop anything outside the vocabulary rather than passing it on: the client
|
|
1232
|
+
# dispatches on `op`, so an unknown verb would be a silent no-op there, and a
|
|
1233
|
+
# malformed one a silent half-edit. Both are worse than a short plan.
|
|
1234
|
+
def normalize_operations(raw)
|
|
1235
|
+
Array(raw).filter_map { |op| normalize_operation(op) }.first(MAX_PLAN_OPERATIONS)
|
|
1236
|
+
end
|
|
1237
|
+
|
|
1238
|
+
def normalize_operation(raw)
|
|
1239
|
+
return nil unless raw.is_a?(Hash)
|
|
1240
|
+
|
|
1241
|
+
builder = PLAN_BUILDERS[raw['op'].to_s.strip]
|
|
1242
|
+
builder && send(builder, raw)
|
|
1243
|
+
end
|
|
1244
|
+
|
|
1245
|
+
def delete_layer_op(raw)
|
|
1246
|
+
layer_only_op('delete_layer', raw)
|
|
1247
|
+
end
|
|
1248
|
+
|
|
1249
|
+
def ungroup_layer_op(raw)
|
|
1250
|
+
layer_only_op('ungroup_layer', raw)
|
|
1251
|
+
end
|
|
1252
|
+
|
|
1253
|
+
def layer_only_op(name, raw)
|
|
1254
|
+
key = layer_key(raw)
|
|
1255
|
+
key.blank? ? nil : { 'op' => name, 'layer' => key }
|
|
1256
|
+
end
|
|
1257
|
+
|
|
1258
|
+
def update_layer_op(raw)
|
|
1259
|
+
key = layer_key(raw)
|
|
1260
|
+
return nil if key.blank?
|
|
1261
|
+
|
|
1262
|
+
attrs = slice_present(raw, PLAN_LAYER_ATTRS)
|
|
1263
|
+
return nil if attrs.empty?
|
|
1264
|
+
|
|
1265
|
+
attrs['cols'] = attrs['cols'].to_i.clamp(1, 6) if attrs.key?('cols')
|
|
1266
|
+
{ 'op' => 'update_layer', 'layer' => key }.merge(attrs)
|
|
1267
|
+
end
|
|
1268
|
+
|
|
1269
|
+
def delete_field_op(raw)
|
|
1270
|
+
key = layer_key(raw)
|
|
1271
|
+
field = raw['field'].to_s.strip
|
|
1272
|
+
return nil if key.blank? || field.blank?
|
|
1273
|
+
|
|
1274
|
+
{ 'op' => 'delete_field', 'layer' => key, 'field' => field }
|
|
1275
|
+
end
|
|
1276
|
+
|
|
1277
|
+
def field_update_op(raw)
|
|
1278
|
+
key = layer_key(raw)
|
|
1279
|
+
field = raw['field'].to_s.strip
|
|
1280
|
+
return nil if key.blank? || field.blank?
|
|
1281
|
+
|
|
1282
|
+
attrs = slice_present(raw, PLAN_FIELD_ATTRS)
|
|
1283
|
+
return nil if attrs.empty?
|
|
1284
|
+
|
|
1285
|
+
PLAN_FIELD_FLAGS.each { |flag| attrs[flag] = truthy?(attrs[flag]) if attrs.key?(flag) }
|
|
1286
|
+
attrs['cols'] = attrs['cols'].to_i.clamp(1, 6) if attrs.key?('cols')
|
|
1287
|
+
{ 'op' => 'set_field', 'layer' => key, 'field' => field }.merge(attrs)
|
|
1288
|
+
end
|
|
1289
|
+
|
|
1290
|
+
def reorder_layers_op(raw)
|
|
1291
|
+
order = string_list(raw['order'])
|
|
1292
|
+
order.size < 2 ? nil : { 'op' => 'reorder_layers', 'order' => order }
|
|
1293
|
+
end
|
|
1294
|
+
|
|
1295
|
+
def group_layers_op(raw)
|
|
1296
|
+
label = raw['label'].to_s.strip
|
|
1297
|
+
layers = string_list(raw['layers'])
|
|
1298
|
+
return nil if label.blank? || layers.empty?
|
|
1299
|
+
|
|
1300
|
+
{ 'op' => 'group_layers', 'label' => label, 'layers' => layers }
|
|
1301
|
+
end
|
|
1302
|
+
|
|
1303
|
+
def layer_key(raw)
|
|
1304
|
+
(raw['layer'] || raw['key']).to_s.strip
|
|
1305
|
+
end
|
|
1306
|
+
|
|
1307
|
+
def string_list(raw)
|
|
1308
|
+
Array(raw).map { |v| v.to_s.strip }.compact_blank.uniq
|
|
1309
|
+
end
|
|
1310
|
+
|
|
1311
|
+
# Keep only the keys the model actually sent, so "absent" (leave alone) stays
|
|
1312
|
+
# distinguishable from "sent as false/blank" (change it).
|
|
1313
|
+
def slice_present(raw, keys)
|
|
1314
|
+
keys.each_with_object({}) { |k, out| out[k] = raw[k] if raw.key?(k) && !raw[k].nil? }
|
|
1315
|
+
end
|
|
1316
|
+
|
|
1317
|
+
def truthy?(value)
|
|
1318
|
+
[true, 'true', 'True', 1, '1'].include?(value)
|
|
1319
|
+
end
|
|
1320
|
+
|
|
926
1321
|
# Tolerate models that wrap JSON in ```json fences despite instructions.
|
|
927
1322
|
def parse_json(text)
|
|
928
1323
|
cleaned = text.strip
|
data/lib/labimotion/version.rb
CHANGED
data/lib/labimotion.rb
CHANGED
|
@@ -88,6 +88,8 @@ module Labimotion
|
|
|
88
88
|
|
|
89
89
|
######## Libs
|
|
90
90
|
autoload :AiTemplate, 'labimotion/libs/ai_template'
|
|
91
|
+
autoload :AiKlassQueue, 'labimotion/libs/ai_klass_queue'
|
|
92
|
+
autoload :AiKlassValidator, 'labimotion/libs/ai_klass_validator'
|
|
91
93
|
autoload :AiEgressGuard, 'labimotion/libs/ai_egress_guard'
|
|
92
94
|
autoload :AiModels, 'labimotion/libs/ai_models'
|
|
93
95
|
autoload :UserAiSettings, 'labimotion/libs/user_ai_settings'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: labimotion
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.4.0.
|
|
4
|
+
version: 2.4.0.rc11
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chia-Lin Lin
|
|
@@ -9,7 +9,7 @@ authors:
|
|
|
9
9
|
autorequire:
|
|
10
10
|
bindir: bin
|
|
11
11
|
cert_chain: []
|
|
12
|
-
date: 2026-08-
|
|
12
|
+
date: 2026-08-26 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: caxlsx
|
|
@@ -145,6 +145,8 @@ files:
|
|
|
145
145
|
- lib/labimotion/helpers/segment_helpers.rb
|
|
146
146
|
- lib/labimotion/helpers/vocabulary_helpers.rb
|
|
147
147
|
- lib/labimotion/libs/ai_egress_guard.rb
|
|
148
|
+
- lib/labimotion/libs/ai_klass_queue.rb
|
|
149
|
+
- lib/labimotion/libs/ai_klass_validator.rb
|
|
148
150
|
- lib/labimotion/libs/ai_models.rb
|
|
149
151
|
- lib/labimotion/libs/ai_template.rb
|
|
150
152
|
- lib/labimotion/libs/attachment_handler.rb
|