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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c6b27ce7b185d248cf78a19fbeb00cf5c60edeed1e090d1fa73c2de8d4465579
4
- data.tar.gz: dd64b3fd5e1fbb717135444ed666df30e2ae542e6e65fb2a3616b210ab9d5832
3
+ metadata.gz: 6355c96e00c582cf1b874e6e4300bbb26a150f276e3afcde63e364e50a7b4f31
4
+ data.tar.gz: 69b3c2d5fa16f295ee2dbdf4b240b0948e1e1f05a3a3edb8c4c4881fecda1137
5
5
  SHA512:
6
- metadata.gz: 3cbf525c1197064ab1b25a11d77fd94249e064d6f705c1ae5e9306bc497e7080c5c2c3266fd8bec52c2ee502862e8b82f6324d3e3aca20d2a6565dbcaa67f811
7
- data.tar.gz: 5c96c935475319618a2c9eb66d5cd102a0971b6f7628a7bd97bcaa5f5cd8686933c6c3cef110d24be30250f01493569ae966f3eb07ecde06ed3e04e710668b64
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 Array(body['choices']).first&.dig('finish_reason')
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
- @history.each do |turn|
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  ## Labimotion Version
4
4
  module Labimotion
5
- VERSION = '2.4.0.rc9'
5
+ VERSION = '2.4.0.rc10'
6
6
  end
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.rc9
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-14 00:00:00.000000000 Z
12
+ date: 2026-08-17 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: caxlsx