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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c6b27ce7b185d248cf78a19fbeb00cf5c60edeed1e090d1fa73c2de8d4465579
4
- data.tar.gz: dd64b3fd5e1fbb717135444ed666df30e2ae542e6e65fb2a3616b210ab9d5832
3
+ metadata.gz: cc8a97c07b6329e1a6be6097c923d710101dfe03494e98463ab2795cea02a590
4
+ data.tar.gz: 7d5535979919fe5709feed7a00860ba6ac24c6c69999e3b1e9f0c03819b3268e
5
5
  SHA512:
6
- metadata.gz: 3cbf525c1197064ab1b25a11d77fd94249e064d6f705c1ae5e9306bc497e7080c5c2c3266fd8bec52c2ee502862e8b82f6324d3e3aca20d2a6565dbcaa67f811
7
- data.tar.gz: 5c96c935475319618a2c9eb66d5cd102a0971b6f7628a7bd97bcaa5f5cd8686933c6c3cef110d24be30250f01493569ae966f3eb07ecde06ed3e04e710668b64
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 https URL with a host. The deep SSRF check (host resolves
103
- # to a public address) runs server-side in the gem at request time; this
104
- # is only a fast format check for immediate UI feedback.
105
- def https_endpoint?(url)
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
- uri.is_a?(URI::HTTPS) && uri.host.present?
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. Raises a 422 otherwise; no-op when unset.
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 https_endpoint?(base_url_arg)
129
+ return if valid_ai_endpoint?(base_url_arg)
124
130
 
125
- error!({ status: false, error: 'The AI provider endpoint must be a valid https:// URL.' }, 422)
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
- ds = Labimotion::DatasetKlass.create!(attributes)
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
- { status: 'success',
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
- new_klass = Labimotion::ElementKlass.create!(attributes)
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
- { status: 'success',
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
- klass = Labimotion::SegmentKlass.create!(attributes)
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
- { status: 'success',
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. Returns url.
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
- raise BlockedError, 'AI provider URL must use https' unless uri.scheme == 'https'
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| !public_ip?(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
- raise "AI request failed (HTTP #{response.code})" unless response.code == 200
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
- raise "AI request failed (HTTP #{response.code})" unless response.code == 200
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
- raise "AI request failed (HTTP #{response.code})" unless response.code == 200
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 Array(body['choices']).first&.dig('finish_reason')
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.to_s[0, 2000]}")
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
- @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
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
@@ -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.rc11'
6
6
  end
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.rc9
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-14 00:00:00.000000000 Z
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