labimotion 2.4.0.rc10 → 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: 6355c96e00c582cf1b874e6e4300bbb26a150f276e3afcde63e364e50a7b4f31
4
- data.tar.gz: 69b3c2d5fa16f295ee2dbdf4b240b0948e1e1f05a3a3edb8c4c4881fecda1137
3
+ metadata.gz: cc8a97c07b6329e1a6be6097c923d710101dfe03494e98463ab2795cea02a590
4
+ data.tar.gz: 7d5535979919fe5709feed7a00860ba6ac24c6c69999e3b1e9f0c03819b3268e
5
5
  SHA512:
6
- metadata.gz: a916242cee0543be6fa0fa04333eaa9dfede1d486f311e0229b97f13120e18b80e58bdbfac234b0429a09e2ba915bafe461be229a52cc64bde1ecd8fd403a597
7
- data.tar.gz: f79f17d2d887a637566ba9bd81123cb1474c75dcb6519813b2dcb770888e1c49c7b55342899f2455423e2a46da3d6056d27229048b431b2fa9c78595c9acfd41
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 }
@@ -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)
@@ -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)
@@ -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
@@ -108,6 +108,9 @@ module Labimotion
108
108
  # A plan longer than this is not a structural edit any more.
109
109
  MAX_PLAN_OPERATIONS = 25
110
110
 
111
+ # How much of a provider's error message is shown to the user.
112
+ MAX_PROVIDER_ERROR_CHARS = 300
113
+
111
114
  # Generate a metadata template for a generic dataset, element or segment.
112
115
  #
113
116
  # The JSON template schema (layers / fields / select_options) is IDENTICAL
@@ -292,7 +295,7 @@ module Labimotion
292
295
  end
293
296
 
294
297
  response = post_messages
295
- raise "AI request failed (HTTP #{response.code})" unless response.code == 200
298
+ request_failed!('generate', response) unless response.code == 200
296
299
 
297
300
  body = JSON.parse(response.body)
298
301
  guard_finish_reason!(body)
@@ -312,7 +315,7 @@ module Labimotion
312
315
  raise 'An instruction is required' if instruction.to_s.strip.blank?
313
316
 
314
317
  response = post_chat(refine_messages(current, instruction))
315
- raise "AI request failed (HTTP #{response.code})" unless response.code == 200
318
+ request_failed!('refine', response) unless response.code == 200
316
319
 
317
320
  body = JSON.parse(response.body)
318
321
  guard_finish_reason!(body)
@@ -345,7 +348,7 @@ module Labimotion
345
348
  raise 'An instruction is required' if instruction.to_s.strip.blank?
346
349
 
347
350
  response = post_chat(plan_messages(index, instruction), PLAN_MAX_TOKENS)
348
- raise "AI request failed (HTTP #{response.code})" unless response.code == 200
351
+ request_failed!('plan', response) unless response.code == 200
349
352
 
350
353
  body = JSON.parse(response.body)
351
354
  raise 'AI request was declined by the content filter' if finish_reason(body) == 'content_filter'
@@ -372,7 +375,7 @@ module Labimotion
372
375
  raise 'This element template has no fields to fill' if schema.empty?
373
376
 
374
377
  response = post_chat(fill_messages(schema, context_text))
375
- raise "AI request failed (HTTP #{response.code})" unless response.code == 200
378
+ request_failed!('fill', response) unless response.code == 200
376
379
 
377
380
  body = JSON.parse(response.body)
378
381
  guard_finish_reason!(body)
@@ -424,16 +427,55 @@ module Labimotion
424
427
  Array(body['choices']).first&.dig('finish_reason').to_s
425
428
  end
426
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
+
427
460
  # Best-effort: record the raw model output (truncated) to log/labimotion.log so a
428
461
  # response that can't be turned into a template can be diagnosed. Never raises.
429
462
  def log_ai_response(context, raw)
430
463
  return if raw.to_s.strip.empty?
431
464
 
432
- Labimotion.logger.warn("AiTemplate #{context}; raw (truncated): #{raw.to_s[0, 2000]}")
465
+ Labimotion.logger.warn("AiTemplate #{context}; raw (truncated): #{loggable(raw)}")
433
466
  rescue StandardError
434
467
  nil
435
468
  end
436
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
+
437
479
  # Map a non-200 status from the ping into a message that points at the setting
438
480
  # most likely at fault. Network/SSRF failures raise before this (from post_chat).
439
481
  def ping_error(code)
@@ -2,5 +2,5 @@
2
2
 
3
3
  ## Labimotion Version
4
4
  module Labimotion
5
- VERSION = '2.4.0.rc10'
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.rc10
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-17 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