labimotion 2.4.0.rc9 → 2.4.0.rc10
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 +37 -0
- data/lib/labimotion/helpers/dataset_helpers.rb +56 -7
- data/lib/labimotion/helpers/element_helpers.rb +1 -1
- data/lib/labimotion/helpers/param_helpers.rb +13 -0
- data/lib/labimotion/libs/ai_template.rb +363 -10
- data/lib/labimotion/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6355c96e00c582cf1b874e6e4300bbb26a150f276e3afcde63e364e50a7b4f31
|
|
4
|
+
data.tar.gz: 69b3c2d5fa16f295ee2dbdf4b240b0948e1e1f05a3a3edb8c4c4881fecda1137
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a916242cee0543be6fa0fa04333eaa9dfede1d486f311e0229b97f13120e18b80e58bdbfac234b0429a09e2ba915bafe461be229a52cc64bde1ecd8fd403a597
|
|
7
|
+
data.tar.gz: f79f17d2d887a637566ba9bd81123cb1474c75dcb6519813b2dcb770888e1c49c7b55342899f2455423e2a46da3d6056d27229048b431b2fa9c78595c9acfd41
|
|
@@ -107,6 +107,43 @@ module Labimotion
|
|
|
107
107
|
end
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
+
namespace :plan_ai_klass do
|
|
111
|
+
# The cheap half of fine-tuning: an instruction plus an INDEX of the open
|
|
112
|
+
# template in, a short list of structural operations out, which the
|
|
113
|
+
# designer applies with the handlers it already has. Type-agnostic like
|
|
114
|
+
# :refine_ai_klass beside it — the path reads as dataset only because
|
|
115
|
+
# that is where this family of routes was first added.
|
|
116
|
+
#
|
|
117
|
+
# Gated on the same whitelist as the AI settings API
|
|
118
|
+
# (Labimotion::MatriceLabimotion, via the host's model): this endpoint
|
|
119
|
+
# spends the user's AI budget, so reaching it needs more than being
|
|
120
|
+
# logged in. NameError, not defined?, for the reason spelled out at
|
|
121
|
+
# generic_element_api's :ai_fill_data — under Zeitwerk `defined?` is nil
|
|
122
|
+
# until the constant is first referenced, and a host that ships no such
|
|
123
|
+
# model is a refusal too.
|
|
124
|
+
before do
|
|
125
|
+
allowed = begin
|
|
126
|
+
Matrice.ai_enabled_for_any?(current_user)
|
|
127
|
+
rescue NameError
|
|
128
|
+
false
|
|
129
|
+
end
|
|
130
|
+
unless allowed
|
|
131
|
+
error!({ status: 'error', message: 'AI template editing is not enabled for this account.' }, 403)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
desc 'plan structural changes to a template with AI (returns operations for the designer to apply)'
|
|
136
|
+
params do
|
|
137
|
+
use :plan_ai_dataset_klass_params
|
|
138
|
+
end
|
|
139
|
+
post do
|
|
140
|
+
plan_ai_dataset_klass(params, current_user)
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
Labimotion.log_exception(e, current_user)
|
|
143
|
+
{ status: 'error', message: e.message }
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
110
147
|
namespace :find_template do
|
|
111
148
|
desc 'Find best matching template for given OLS term ID'
|
|
112
149
|
params do
|
|
@@ -144,18 +144,67 @@ module Labimotion
|
|
|
144
144
|
cols: params[:cols],
|
|
145
145
|
**overrides
|
|
146
146
|
)
|
|
147
|
-
|
|
148
|
-
status: 'success',
|
|
149
|
-
label: ai['label'].presence || params[:label],
|
|
150
|
-
layers: ai['layers'],
|
|
151
|
-
select_options: ai['select_options'],
|
|
152
|
-
summary: ai['summary']
|
|
153
|
-
}
|
|
147
|
+
refine_outcome(ai, params[:label])
|
|
154
148
|
rescue StandardError => e
|
|
155
149
|
Labimotion.log_exception(e, current_user)
|
|
156
150
|
{ status: 'error', message: e.message }
|
|
157
151
|
end
|
|
158
152
|
|
|
153
|
+
# Turn an instruction into a list of STRUCTURAL operations the designer applies
|
|
154
|
+
# itself, from an INDEX of the open template rather than the template. A layout
|
|
155
|
+
# change then costs what the sentence costs, not what the template costs.
|
|
156
|
+
#
|
|
157
|
+
# Four outcomes, three of them normal:
|
|
158
|
+
# 'success' — operations to apply
|
|
159
|
+
# 'design' — not expressible as operations; the client re-asks on refine
|
|
160
|
+
# 'unrelated' — not a template change at all; the turn STOPS here
|
|
161
|
+
# 'error' — the request failed
|
|
162
|
+
# 'design' is deliberately not an error: it is the routing answer for every
|
|
163
|
+
# instruction that needs new fields, wording, units or ontology terms.
|
|
164
|
+
# 'unrelated' exists so an off-topic message does not fall through to refine,
|
|
165
|
+
# where the whole template would be re-emitted to report that nothing changed.
|
|
166
|
+
def plan_ai_dataset_klass(params, current_user)
|
|
167
|
+
instruction = params[:instruction].to_s.strip
|
|
168
|
+
raise 'An instruction is required' if instruction.blank?
|
|
169
|
+
|
|
170
|
+
overrides = ai_user_overrides(current_user)
|
|
171
|
+
ai = Labimotion::AiTemplate.plan(
|
|
172
|
+
index: params[:index] || {},
|
|
173
|
+
instruction: instruction,
|
|
174
|
+
ols_term_id: ai_term_with_label(params[:ols_term_id]),
|
|
175
|
+
history: params[:history],
|
|
176
|
+
**overrides
|
|
177
|
+
)
|
|
178
|
+
plan_outcome(ai)
|
|
179
|
+
rescue StandardError => e
|
|
180
|
+
Labimotion.log_exception(e, current_user)
|
|
181
|
+
{ status: 'error', message: e.message }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def refine_outcome(refined, fallback_label)
|
|
185
|
+
{
|
|
186
|
+
status: 'success',
|
|
187
|
+
label: refined['label'].presence || fallback_label,
|
|
188
|
+
layers: refined['layers'],
|
|
189
|
+
select_options: refined['select_options'],
|
|
190
|
+
summary: refined['summary'],
|
|
191
|
+
usage: refined['usage'],
|
|
192
|
+
model: refined['model']
|
|
193
|
+
}
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Which of the three normal outcomes this plan is. `model` rides on all of
|
|
197
|
+
# them: it is what actually answered, which is not always what the user
|
|
198
|
+
# picked — a keyless user's choice is clamped to the server allowlist.
|
|
199
|
+
def plan_outcome(plan)
|
|
200
|
+
base = { reason: plan['reason'], usage: plan['usage'], model: plan['model'] }
|
|
201
|
+
return base.merge(status: 'unrelated') if plan['unrelated']
|
|
202
|
+
return base.merge(status: 'design') if plan['needs_design']
|
|
203
|
+
|
|
204
|
+
{ status: 'success', operations: plan['operations'], summary: plan['summary'],
|
|
205
|
+
usage: plan['usage'], model: plan['model'] }
|
|
206
|
+
end
|
|
207
|
+
|
|
159
208
|
def find_best_match_template(ols_term_id)
|
|
160
209
|
result = Labimotion::TemplateMatcher.find_best_match(ols_term_id)
|
|
161
210
|
if result[:template]
|
|
@@ -141,7 +141,7 @@ module Labimotion
|
|
|
141
141
|
instructions: ai_fill_instructions(element),
|
|
142
142
|
**overrides
|
|
143
143
|
)
|
|
144
|
-
{ status: 'success', values: ai['values'], summary: ai['summary'] }
|
|
144
|
+
{ status: 'success', values: ai['values'], summary: ai['summary'], usage: ai['usage'], model: ai['model'] }
|
|
145
145
|
rescue StandardError => e
|
|
146
146
|
Labimotion.log_exception(e, current_user)
|
|
147
147
|
{ 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'
|
|
@@ -72,6 +72,42 @@ 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
|
+
|
|
75
111
|
# Generate a metadata template for a generic dataset, element or segment.
|
|
76
112
|
#
|
|
77
113
|
# The JSON template schema (layers / fields / select_options) is IDENTICAL
|
|
@@ -115,6 +151,33 @@ module Labimotion
|
|
|
115
151
|
.refine(current: current, instruction: instruction)
|
|
116
152
|
end
|
|
117
153
|
|
|
154
|
+
# Turn a natural-language instruction into a list of STRUCTURAL operations,
|
|
155
|
+
# without sending the template or asking the model to re-emit it.
|
|
156
|
+
#
|
|
157
|
+
# Refine's cost is set by the template, not the request: the model is told to
|
|
158
|
+
# reproduce every layer and field verbatim, so "delete the processing layer"
|
|
159
|
+
# bills the same thousands of output tokens as a redesign — and on a long
|
|
160
|
+
# template the model stops mid-copy, which surfaces as an unparseable
|
|
161
|
+
# response. Neither is inherent to the request. Deleting a layer, grouping
|
|
162
|
+
# two, reordering, flipping `required` are all things chem-generic-ui already
|
|
163
|
+
# does deterministically, cascades and workflow guard included; the model is
|
|
164
|
+
# only needed to read the sentence and name the layer.
|
|
165
|
+
#
|
|
166
|
+
# So this path sends an INDEX (keys, labels, types) and takes back a short op
|
|
167
|
+
# list the designer applies itself. When the instruction needs judgement the
|
|
168
|
+
# ops cannot express — new fields, wording, units, ontology terms — the model
|
|
169
|
+
# says so and the caller falls back to refine.
|
|
170
|
+
#
|
|
171
|
+
# @param index [Hash] compact template index (see chem-generic-ui buildTemplateIndex)
|
|
172
|
+
# @param instruction [String] the change to apply
|
|
173
|
+
# @param history [Array<Hash>] prior turns [{ 'role' => .., 'content' => .. }]
|
|
174
|
+
# @return [Hash] { 'operations' => Array, 'summary' => String,
|
|
175
|
+
# 'needs_design' => Boolean, 'reason' => String, 'usage' => Hash }
|
|
176
|
+
def self.plan(index:, instruction:, ols_term_id: nil, history: [], model: nil, api_key: nil, base_url: nil, api_path: nil)
|
|
177
|
+
new(ols_term_id: ols_term_id, history: history, model: model, api_key: api_key, base_url: base_url, api_path: api_path)
|
|
178
|
+
.plan(index: index, instruction: instruction)
|
|
179
|
+
end
|
|
180
|
+
|
|
118
181
|
# Extract DATA VALUES for an existing generic element/segment/dataset instance
|
|
119
182
|
# from a document's text, to pre-fill the working copy for human review. This
|
|
120
183
|
# does NOT design or modify a template — it only reads values for the fields the
|
|
@@ -237,7 +300,7 @@ module Labimotion
|
|
|
237
300
|
text = extract_text(body)
|
|
238
301
|
raise 'AI returned an empty response' if text.blank?
|
|
239
302
|
|
|
240
|
-
normalize(parse_json(text))
|
|
303
|
+
normalize(parse_json(text)).merge('usage' => usage_from(body))
|
|
241
304
|
rescue JSON::ParserError => e
|
|
242
305
|
log_ai_response('generate could not parse response', response&.body)
|
|
243
306
|
Labimotion.log_exception(e)
|
|
@@ -268,6 +331,8 @@ module Labimotion
|
|
|
268
331
|
end
|
|
269
332
|
|
|
270
333
|
result['summary'] = (data.is_a?(Hash) ? data['summary'].to_s.strip : '')
|
|
334
|
+
result['usage'] = usage_from(body)
|
|
335
|
+
result['model'] = model
|
|
271
336
|
result
|
|
272
337
|
rescue JSON::ParserError => e
|
|
273
338
|
log_ai_response('refine could not parse response', response&.body)
|
|
@@ -275,6 +340,25 @@ module Labimotion
|
|
|
275
340
|
raise 'AI returned a response that could not be parsed as a template'
|
|
276
341
|
end
|
|
277
342
|
|
|
343
|
+
def plan(index:, instruction:)
|
|
344
|
+
raise 'AI API key is not configured (set config/labimotion_ai.yml :api_key or KI_TOOLBOX_API_KEY)' if api_key.blank?
|
|
345
|
+
raise 'An instruction is required' if instruction.to_s.strip.blank?
|
|
346
|
+
|
|
347
|
+
response = post_chat(plan_messages(index, instruction), PLAN_MAX_TOKENS)
|
|
348
|
+
raise "AI request failed (HTTP #{response.code})" unless response.code == 200
|
|
349
|
+
|
|
350
|
+
body = JSON.parse(response.body)
|
|
351
|
+
raise 'AI request was declined by the content filter' if finish_reason(body) == 'content_filter'
|
|
352
|
+
|
|
353
|
+
plan_from(body)
|
|
354
|
+
rescue JSON::ParserError => e
|
|
355
|
+
log_ai_response('plan could not parse response', response&.body)
|
|
356
|
+
Labimotion.log_exception(e)
|
|
357
|
+
# An unparseable PLAN is not a failed request — it is a signal to take the
|
|
358
|
+
# slower path, which is exactly what escalation asks the caller to do.
|
|
359
|
+
escalate('The model did not return a usable plan.')
|
|
360
|
+
end
|
|
361
|
+
|
|
278
362
|
def fill(properties:, context_text:, instructions: nil)
|
|
279
363
|
raise 'AI API key is not configured (set config/labimotion_ai.yml :api_key or KI_TOOLBOX_API_KEY)' if api_key.blank?
|
|
280
364
|
raise 'No readable text could be extracted from the selected source' if context_text.to_s.strip.blank?
|
|
@@ -300,7 +384,9 @@ module Labimotion
|
|
|
300
384
|
raw_values = data.is_a?(Hash) ? data['values'] : nil
|
|
301
385
|
{
|
|
302
386
|
'values' => normalize_fill_values(props, raw_values),
|
|
303
|
-
'summary' => (data.is_a?(Hash) ? data['summary'].to_s.strip : '')
|
|
387
|
+
'summary' => (data.is_a?(Hash) ? data['summary'].to_s.strip : ''),
|
|
388
|
+
'usage' => usage_from(body),
|
|
389
|
+
'model' => model
|
|
304
390
|
}
|
|
305
391
|
rescue JSON::ParserError => e
|
|
306
392
|
log_ai_response('fill could not parse response', response&.body)
|
|
@@ -325,7 +411,7 @@ module Labimotion
|
|
|
325
411
|
# content-filter block, or a response truncated at the token limit — the latter
|
|
326
412
|
# would otherwise surface as a confusing parse/empty-template error.
|
|
327
413
|
def guard_finish_reason!(body)
|
|
328
|
-
case
|
|
414
|
+
case finish_reason(body)
|
|
329
415
|
when 'content_filter'
|
|
330
416
|
raise 'AI request was declined by the content filter'
|
|
331
417
|
when 'length'
|
|
@@ -334,6 +420,10 @@ module Labimotion
|
|
|
334
420
|
end
|
|
335
421
|
end
|
|
336
422
|
|
|
423
|
+
def finish_reason(body)
|
|
424
|
+
Array(body['choices']).first&.dig('finish_reason').to_s
|
|
425
|
+
end
|
|
426
|
+
|
|
337
427
|
# Best-effort: record the raw model output (truncated) to log/labimotion.log so a
|
|
338
428
|
# response that can't be turned into a template can be diagnosed. Never raises.
|
|
339
429
|
def log_ai_response(context, raw)
|
|
@@ -452,6 +542,24 @@ module Labimotion
|
|
|
452
542
|
'model in your AI settings, or raise :timeout in config/labimotion_ai.yml.'
|
|
453
543
|
end
|
|
454
544
|
|
|
545
|
+
# What the call cost, as the provider counted it. OpenAI-compatible responses
|
|
546
|
+
# carry it in `usage`; KI-Toolbox does, and it is the only per-request figure
|
|
547
|
+
# available — the gateway exposes no account-level usage API.
|
|
548
|
+
#
|
|
549
|
+
# nil when the provider reports nothing, rather than zeros: "not reported" and
|
|
550
|
+
# "cost nothing" are different answers and a caller should be able to tell
|
|
551
|
+
# them apart before showing a number to a user.
|
|
552
|
+
def usage_from(body)
|
|
553
|
+
usage = body['usage']
|
|
554
|
+
return nil unless usage.is_a?(Hash)
|
|
555
|
+
|
|
556
|
+
{
|
|
557
|
+
'prompt' => usage['prompt_tokens'],
|
|
558
|
+
'completion' => usage['completion_tokens'],
|
|
559
|
+
'total' => usage['total_tokens']
|
|
560
|
+
}.compact.presence
|
|
561
|
+
end
|
|
562
|
+
|
|
455
563
|
# OpenAI-compatible chat completions return the text at
|
|
456
564
|
# choices[0].message.content.
|
|
457
565
|
def extract_text(body)
|
|
@@ -726,13 +834,7 @@ module Labimotion
|
|
|
726
834
|
def refine_messages(current, instruction)
|
|
727
835
|
messages = [{ role: 'system', content: refine_system_content(current) }]
|
|
728
836
|
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
|
|
837
|
+
messages.concat(history_messages)
|
|
736
838
|
messages << { role: 'user', content: refine_instruction_prompt(instruction) }
|
|
737
839
|
messages
|
|
738
840
|
end
|
|
@@ -923,6 +1025,257 @@ module Labimotion
|
|
|
923
1025
|
PROMPT
|
|
924
1026
|
end
|
|
925
1027
|
|
|
1028
|
+
# --- structural plan ----------------------------------------------------
|
|
1029
|
+
|
|
1030
|
+
def plan_messages(index, instruction)
|
|
1031
|
+
messages = [{ role: 'system', content: plan_system_content }]
|
|
1032
|
+
messages << { role: 'user', content: plan_index_context(index) }
|
|
1033
|
+
messages.concat(history_messages)
|
|
1034
|
+
messages << { role: 'user', content: plan_instruction_prompt(instruction) }
|
|
1035
|
+
messages
|
|
1036
|
+
end
|
|
1037
|
+
|
|
1038
|
+
def plan_system_content
|
|
1039
|
+
cfg[:plan_system_prompt].presence || default_plan_system_prompt
|
|
1040
|
+
end
|
|
1041
|
+
|
|
1042
|
+
# Written against the operations chem-generic-ui can already apply. Every rule
|
|
1043
|
+
# here is about NAMING an existing thing, never about describing one — which
|
|
1044
|
+
# is why this prompt is a fraction of refine's and why its reply is bounded:
|
|
1045
|
+
# there is nothing in the vocabulary whose length grows with the template.
|
|
1046
|
+
def default_plan_system_prompt
|
|
1047
|
+
<<~PROMPT
|
|
1048
|
+
You turn a change request for a LabIMotion metadata template into a
|
|
1049
|
+
STRUCTURAL PLAN: a short list of operations the designer applies itself.
|
|
1050
|
+
You never write templates, layers or fields.
|
|
1051
|
+
|
|
1052
|
+
You are given an INDEX of the open template — its layers (key, label,
|
|
1053
|
+
columns, group) and, per layer, its fields (key, label, type). It omits
|
|
1054
|
+
descriptions, options, units and restrictions.
|
|
1055
|
+
|
|
1056
|
+
Reply with a SINGLE JSON object — no prose, no markdown, no code fences —
|
|
1057
|
+
in one of exactly three shapes:
|
|
1058
|
+
|
|
1059
|
+
A { "operations": [ ... ], "summary": "one sentence describing the change" }
|
|
1060
|
+
The operations below can carry the request out.
|
|
1061
|
+
|
|
1062
|
+
B { "needs_design": true, "reason": "<short reason>" }
|
|
1063
|
+
It is about this template, but the operations cannot express it
|
|
1064
|
+
(adding a field or layer, wording, types, units, options, ontology
|
|
1065
|
+
terms, moving a field between layers, merging layers, renaming a
|
|
1066
|
+
key), or you cannot tell which layer or field it means, or it is only
|
|
1067
|
+
partly structural — a partial plan would silently drop the rest.
|
|
1068
|
+
|
|
1069
|
+
C { "unrelated": true, "reason": "<one sentence, what this dialog is for>" }
|
|
1070
|
+
The message asks for NO change to anything — a greeting, a question
|
|
1071
|
+
about the world, arithmetic, chit-chat. Do not answer the message.
|
|
1072
|
+
|
|
1073
|
+
C is decided by what the message ASKS FOR, never by what you can do with
|
|
1074
|
+
it. Any request to change the template — however vague, however badly it
|
|
1075
|
+
names things, "tidy this up" included — is A or B, never C.
|
|
1076
|
+
|
|
1077
|
+
"delete the results layer" -> A
|
|
1078
|
+
"make the operator field required" -> A
|
|
1079
|
+
"put setup and acquisition in one group" -> A
|
|
1080
|
+
"add a field for the serial number" -> B
|
|
1081
|
+
"tidy this up" -> B
|
|
1082
|
+
"remove the physical description bit" -> A if a layer matches, else B
|
|
1083
|
+
"1+1=?" -> C
|
|
1084
|
+
"hello" -> C
|
|
1085
|
+
"what is the boiling point of water?" -> C
|
|
1086
|
+
|
|
1087
|
+
Operations, and nothing else:
|
|
1088
|
+
- {"op":"delete_layer","layer":"<layer_key>"}
|
|
1089
|
+
- {"op":"update_layer","layer":"<layer_key>","label":"..","cols":1-6,"color":"default"}
|
|
1090
|
+
- {"op":"delete_field","layer":"<layer_key>","field":"<field_key>"}
|
|
1091
|
+
- {"op":"set_field","layer":"<layer_key>","field":"<field_key>","required":true,
|
|
1092
|
+
"readonly":false,"label":"..","description":"..","placeholder":"..","cols":1-6,"hasOwnRow":true}
|
|
1093
|
+
- {"op":"reorder_layers","order":["<layer_key>", ...]}
|
|
1094
|
+
- {"op":"group_layers","label":"<group label>","layers":["<layer_key>", ...]}
|
|
1095
|
+
- {"op":"ungroup_layer","layer":"<layer_key>"}
|
|
1096
|
+
|
|
1097
|
+
- Match what a request names against the index by label, ignoring case,
|
|
1098
|
+
plurals and small wording differences; take the closest match. A name
|
|
1099
|
+
that matches nothing is B, never C.
|
|
1100
|
+
- Use the index's exact layer_key and field_key strings; never invent one.
|
|
1101
|
+
- Send only the attributes the request asks you to change; never pad.
|
|
1102
|
+
- set_field CHANGES a field already in the index and can never add one.
|
|
1103
|
+
A request to ADD a field or layer is B, even when something similar
|
|
1104
|
+
is already there.
|
|
1105
|
+
- group_layers needs only its "label": free text you choose, no id.
|
|
1106
|
+
Grouping existing layers is always A, never B.
|
|
1107
|
+
- reorder_layers takes EVERY layer key, in the new order.
|
|
1108
|
+
PROMPT
|
|
1109
|
+
end
|
|
1110
|
+
|
|
1111
|
+
def plan_index_context(index)
|
|
1112
|
+
index = {} unless index.is_a?(Hash)
|
|
1113
|
+
"Template index (JSON):\n#{JSON.generate(index)}"
|
|
1114
|
+
end
|
|
1115
|
+
|
|
1116
|
+
def plan_instruction_prompt(instruction)
|
|
1117
|
+
<<~PROMPT.strip
|
|
1118
|
+
Change request: #{instruction}
|
|
1119
|
+
|
|
1120
|
+
Answer with the structural plan for this request, with needs_design if it
|
|
1121
|
+
cannot be expressed by the operations above, or with unrelated if it is
|
|
1122
|
+
not a request to change this template.
|
|
1123
|
+
PROMPT
|
|
1124
|
+
end
|
|
1125
|
+
|
|
1126
|
+
# Prior turns, shared by refine and plan. Both need the same continuity and
|
|
1127
|
+
# the same coercion of an untrusted role to one of the two the API accepts.
|
|
1128
|
+
def history_messages
|
|
1129
|
+
@history.filter_map do |turn|
|
|
1130
|
+
content = (turn['content'] || turn[:content]).to_s
|
|
1131
|
+
next if content.strip.blank?
|
|
1132
|
+
|
|
1133
|
+
role = (turn['role'] || turn[:role]).to_s
|
|
1134
|
+
{ role: (role == 'assistant' ? 'assistant' : 'user'), content: content }
|
|
1135
|
+
end
|
|
1136
|
+
end
|
|
1137
|
+
|
|
1138
|
+
def plan_from(body)
|
|
1139
|
+
usage = usage_from(body)
|
|
1140
|
+
# A plan that hits the ceiling is a model writing a template instead of
|
|
1141
|
+
# planning. Escalate rather than raise — refine can still serve this one.
|
|
1142
|
+
return escalate('The model returned a template instead of a plan.', usage) if finish_reason(body) == 'length'
|
|
1143
|
+
|
|
1144
|
+
text = extract_text(body)
|
|
1145
|
+
return escalate('AI returned an empty response.', usage) if text.blank?
|
|
1146
|
+
|
|
1147
|
+
data = parse_json(text)
|
|
1148
|
+
data = {} unless data.is_a?(Hash)
|
|
1149
|
+
return unrelated(data['reason'].to_s.strip, usage) if truthy?(data['unrelated'])
|
|
1150
|
+
return escalate(data['reason'].to_s.strip, usage) if truthy?(data['needs_design'])
|
|
1151
|
+
|
|
1152
|
+
operations = normalize_operations(data['operations'])
|
|
1153
|
+
return escalate('The request did not map to any structural operation.', usage) if operations.empty?
|
|
1154
|
+
|
|
1155
|
+
plan_result(usage).merge('operations' => operations, 'summary' => data['summary'].to_s.strip)
|
|
1156
|
+
end
|
|
1157
|
+
|
|
1158
|
+
# "Not structural" is a routing answer, not a failure: the caller re-asks on
|
|
1159
|
+
# the refine path. usage still rides along — the attempt was billed.
|
|
1160
|
+
def escalate(reason, usage = nil)
|
|
1161
|
+
plan_result(usage).merge(
|
|
1162
|
+
'needs_design' => true,
|
|
1163
|
+
'reason' => reason.presence || 'This request needs template design.'
|
|
1164
|
+
)
|
|
1165
|
+
end
|
|
1166
|
+
|
|
1167
|
+
# Neither structural nor design: the message was not a change to this
|
|
1168
|
+
# template at all. Escalating it would send the whole template to be re-emitted
|
|
1169
|
+
# verbatim so the model can reply that there was nothing to do — thousands of
|
|
1170
|
+
# output tokens for an answer the plan call already has. So this outcome stops
|
|
1171
|
+
# the turn instead of falling through to refine.
|
|
1172
|
+
def unrelated(reason, usage = nil)
|
|
1173
|
+
plan_result(usage).merge(
|
|
1174
|
+
'unrelated' => true,
|
|
1175
|
+
'reason' => reason.presence || 'That is not a change to this template.'
|
|
1176
|
+
)
|
|
1177
|
+
end
|
|
1178
|
+
|
|
1179
|
+
# `model` is what actually served the request, not what the user picked: a
|
|
1180
|
+
# keyless user's choice is clamped to the server allowlist, so the two can
|
|
1181
|
+
# differ and only this one is the answer to "which model ran?".
|
|
1182
|
+
def plan_result(usage)
|
|
1183
|
+
{
|
|
1184
|
+
'operations' => [], 'summary' => '', 'needs_design' => false,
|
|
1185
|
+
'unrelated' => false, 'usage' => usage, 'model' => model
|
|
1186
|
+
}
|
|
1187
|
+
end
|
|
1188
|
+
|
|
1189
|
+
# Drop anything outside the vocabulary rather than passing it on: the client
|
|
1190
|
+
# dispatches on `op`, so an unknown verb would be a silent no-op there, and a
|
|
1191
|
+
# malformed one a silent half-edit. Both are worse than a short plan.
|
|
1192
|
+
def normalize_operations(raw)
|
|
1193
|
+
Array(raw).filter_map { |op| normalize_operation(op) }.first(MAX_PLAN_OPERATIONS)
|
|
1194
|
+
end
|
|
1195
|
+
|
|
1196
|
+
def normalize_operation(raw)
|
|
1197
|
+
return nil unless raw.is_a?(Hash)
|
|
1198
|
+
|
|
1199
|
+
builder = PLAN_BUILDERS[raw['op'].to_s.strip]
|
|
1200
|
+
builder && send(builder, raw)
|
|
1201
|
+
end
|
|
1202
|
+
|
|
1203
|
+
def delete_layer_op(raw)
|
|
1204
|
+
layer_only_op('delete_layer', raw)
|
|
1205
|
+
end
|
|
1206
|
+
|
|
1207
|
+
def ungroup_layer_op(raw)
|
|
1208
|
+
layer_only_op('ungroup_layer', raw)
|
|
1209
|
+
end
|
|
1210
|
+
|
|
1211
|
+
def layer_only_op(name, raw)
|
|
1212
|
+
key = layer_key(raw)
|
|
1213
|
+
key.blank? ? nil : { 'op' => name, 'layer' => key }
|
|
1214
|
+
end
|
|
1215
|
+
|
|
1216
|
+
def update_layer_op(raw)
|
|
1217
|
+
key = layer_key(raw)
|
|
1218
|
+
return nil if key.blank?
|
|
1219
|
+
|
|
1220
|
+
attrs = slice_present(raw, PLAN_LAYER_ATTRS)
|
|
1221
|
+
return nil if attrs.empty?
|
|
1222
|
+
|
|
1223
|
+
attrs['cols'] = attrs['cols'].to_i.clamp(1, 6) if attrs.key?('cols')
|
|
1224
|
+
{ 'op' => 'update_layer', 'layer' => key }.merge(attrs)
|
|
1225
|
+
end
|
|
1226
|
+
|
|
1227
|
+
def delete_field_op(raw)
|
|
1228
|
+
key = layer_key(raw)
|
|
1229
|
+
field = raw['field'].to_s.strip
|
|
1230
|
+
return nil if key.blank? || field.blank?
|
|
1231
|
+
|
|
1232
|
+
{ 'op' => 'delete_field', 'layer' => key, 'field' => field }
|
|
1233
|
+
end
|
|
1234
|
+
|
|
1235
|
+
def field_update_op(raw)
|
|
1236
|
+
key = layer_key(raw)
|
|
1237
|
+
field = raw['field'].to_s.strip
|
|
1238
|
+
return nil if key.blank? || field.blank?
|
|
1239
|
+
|
|
1240
|
+
attrs = slice_present(raw, PLAN_FIELD_ATTRS)
|
|
1241
|
+
return nil if attrs.empty?
|
|
1242
|
+
|
|
1243
|
+
PLAN_FIELD_FLAGS.each { |flag| attrs[flag] = truthy?(attrs[flag]) if attrs.key?(flag) }
|
|
1244
|
+
attrs['cols'] = attrs['cols'].to_i.clamp(1, 6) if attrs.key?('cols')
|
|
1245
|
+
{ 'op' => 'set_field', 'layer' => key, 'field' => field }.merge(attrs)
|
|
1246
|
+
end
|
|
1247
|
+
|
|
1248
|
+
def reorder_layers_op(raw)
|
|
1249
|
+
order = string_list(raw['order'])
|
|
1250
|
+
order.size < 2 ? nil : { 'op' => 'reorder_layers', 'order' => order }
|
|
1251
|
+
end
|
|
1252
|
+
|
|
1253
|
+
def group_layers_op(raw)
|
|
1254
|
+
label = raw['label'].to_s.strip
|
|
1255
|
+
layers = string_list(raw['layers'])
|
|
1256
|
+
return nil if label.blank? || layers.empty?
|
|
1257
|
+
|
|
1258
|
+
{ 'op' => 'group_layers', 'label' => label, 'layers' => layers }
|
|
1259
|
+
end
|
|
1260
|
+
|
|
1261
|
+
def layer_key(raw)
|
|
1262
|
+
(raw['layer'] || raw['key']).to_s.strip
|
|
1263
|
+
end
|
|
1264
|
+
|
|
1265
|
+
def string_list(raw)
|
|
1266
|
+
Array(raw).map { |v| v.to_s.strip }.compact_blank.uniq
|
|
1267
|
+
end
|
|
1268
|
+
|
|
1269
|
+
# Keep only the keys the model actually sent, so "absent" (leave alone) stays
|
|
1270
|
+
# distinguishable from "sent as false/blank" (change it).
|
|
1271
|
+
def slice_present(raw, keys)
|
|
1272
|
+
keys.each_with_object({}) { |k, out| out[k] = raw[k] if raw.key?(k) && !raw[k].nil? }
|
|
1273
|
+
end
|
|
1274
|
+
|
|
1275
|
+
def truthy?(value)
|
|
1276
|
+
[true, 'true', 'True', 1, '1'].include?(value)
|
|
1277
|
+
end
|
|
1278
|
+
|
|
926
1279
|
# Tolerate models that wrap JSON in ```json fences despite instructions.
|
|
927
1280
|
def parse_json(text)
|
|
928
1281
|
cleaned = text.strip
|
data/lib/labimotion/version.rb
CHANGED
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.rc10
|
|
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-17 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: caxlsx
|