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 +4 -4
- data/lib/labimotion/apis/generic_dataset_api.rb +15 -0
- data/lib/labimotion/apis/generic_element_api.rb +15 -0
- data/lib/labimotion/apis/labimotion_ai_api.rb +16 -9
- data/lib/labimotion/apis/segment_api.rb +16 -0
- data/lib/labimotion/helpers/dataset_helpers.rb +17 -2
- data/lib/labimotion/helpers/element_helpers.rb +17 -2
- data/lib/labimotion/helpers/segment_helpers.rb +17 -2
- data/lib/labimotion/libs/ai_egress_guard.rb +18 -3
- data/lib/labimotion/libs/ai_klass_queue.rb +88 -0
- data/lib/labimotion/libs/ai_klass_validator.rb +74 -0
- data/lib/labimotion/libs/ai_template.rb +47 -5
- data/lib/labimotion/version.rb +1 -1
- data/lib/labimotion.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cc8a97c07b6329e1a6be6097c923d710101dfe03494e98463ab2795cea02a590
|
|
4
|
+
data.tar.gz: 7d5535979919fe5709feed7a00860ba6ac24c6c69999e3b1e9f0c03819b3268e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 79b2acc5223830bb6c4444342173b80af2ad4611f095e07cfcc89861473cf5f2e2854b0ed67e098b8c9bd94fe9285178f06fb20c7ae84729d5938d567a490531
|
|
7
|
+
data.tar.gz: b657890bf6c80d13283ba1936678c2c364308e80ba0cf01b51f36d129012873c47b2a9393724d2154307ba1ee267708a00992a49038428c3abe8160ef607eeb2
|
|
@@ -85,6 +85,21 @@ module Labimotion
|
|
|
85
85
|
use :create_ai_dataset_klass_params
|
|
86
86
|
end
|
|
87
87
|
post do
|
|
88
|
+
# Settle the cheap objections BEFORE spending a provider call on this:
|
|
89
|
+
# a taken name is knowable now, and finding out after generation means
|
|
90
|
+
# 15-90s and a token spend for an answer a lookup could have given.
|
|
91
|
+
validation = Labimotion::AiKlassValidator.error_for(kind: 'dataset', params: params)
|
|
92
|
+
next { status: 'error', message: validation } if validation
|
|
93
|
+
|
|
94
|
+
# Generation is a long provider call, so it is handed to the host's worker
|
|
95
|
+
# and the request answers at once; the result arrives as a notification
|
|
96
|
+
# linking back into the designer. A host with no such worker gets the
|
|
97
|
+
# old inline behaviour rather than an error.
|
|
98
|
+
if Labimotion::AiKlassQueue.queue(kind: 'dataset', params: params, user: current_user)
|
|
99
|
+
next { status: 'queued',
|
|
100
|
+
message: 'Generating the template in the background. You will be notified when it is ready.' }
|
|
101
|
+
end
|
|
102
|
+
|
|
88
103
|
msg = create_ai_dataset_klass(params, current_user)
|
|
89
104
|
klass = Labimotion::DatasetKlassEntity.represent(Labimotion::DatasetKlass.all)
|
|
90
105
|
{ status: msg[:status], message: msg[:message], klass: klass }
|
|
@@ -219,6 +219,21 @@ module Labimotion
|
|
|
219
219
|
end
|
|
220
220
|
post do
|
|
221
221
|
authenticate_admin!('elements')
|
|
222
|
+
# Settle the cheap objections BEFORE spending a provider call on this:
|
|
223
|
+
# a taken name is knowable now, and finding out after generation means
|
|
224
|
+
# 15-90s and a token spend for an answer a lookup could have given.
|
|
225
|
+
validation = Labimotion::AiKlassValidator.error_for(kind: 'element', params: params)
|
|
226
|
+
next { status: 'error', message: validation } if validation
|
|
227
|
+
|
|
228
|
+
# Generation is a long provider call, so it is handed to the host's worker
|
|
229
|
+
# and the request answers at once; the result arrives as a notification
|
|
230
|
+
# linking back into the designer. A host with no such worker gets the
|
|
231
|
+
# old inline behaviour rather than an error.
|
|
232
|
+
if Labimotion::AiKlassQueue.queue(kind: 'element', params: params, user: current_user)
|
|
233
|
+
next { status: 'queued',
|
|
234
|
+
message: 'Generating the template in the background. You will be notified when it is ready.' }
|
|
235
|
+
end
|
|
236
|
+
|
|
222
237
|
msg = create_ai_element_klass(params, current_user)
|
|
223
238
|
{ status: msg[:status], message: msg[:message],
|
|
224
239
|
klass: Labimotion::ElementKlassEntity.represent(Labimotion::ElementKlass.where(is_active: true)) }
|
|
@@ -17,6 +17,7 @@ module Labimotion
|
|
|
17
17
|
# :models) lists none. A representative subset of the chat-capable KI-Toolbox
|
|
18
18
|
# models (ki-toolbox.scc.kit.edu); the full list lives in the yml.
|
|
19
19
|
DEFAULT_MODELS = %w[
|
|
20
|
+
models/DeepSeek-R1-Distill-Qwen-1.5B-Q4_K_M.gguf
|
|
20
21
|
kit.mistral-small-4-119b-a8b
|
|
21
22
|
azure.gpt-4.1-mini
|
|
22
23
|
azure.gpt-4.1
|
|
@@ -99,18 +100,23 @@ module Labimotion
|
|
|
99
100
|
(ids << ENV['KI_TOOLBOX_MODEL'].to_s).compact_blank.uniq
|
|
100
101
|
end
|
|
101
102
|
|
|
102
|
-
# A well-formed
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
|
|
103
|
+
# A well-formed URL with a host, https only. In non-production envs
|
|
104
|
+
# (dev/test against local providers) http is also accepted. The deep
|
|
105
|
+
# SSRF check (host resolves to a public address) runs server-side in
|
|
106
|
+
# the gem at request time; this is only a fast format check for
|
|
107
|
+
# immediate UI feedback.
|
|
108
|
+
def valid_ai_endpoint?(url)
|
|
106
109
|
uri = URI.parse(url.to_s.strip)
|
|
107
|
-
|
|
110
|
+
return false unless uri.host.present?
|
|
111
|
+
|
|
112
|
+
Rails.env.production? ? uri.is_a?(URI::HTTPS) : uri.is_a?(URI::HTTP)
|
|
108
113
|
rescue URI::InvalidURIError
|
|
109
114
|
false
|
|
110
115
|
end
|
|
111
116
|
|
|
112
117
|
# A personal provider endpoint is only usable with a personal key, and
|
|
113
|
-
# must be a valid https URL
|
|
118
|
+
# must be a valid https URL (http allowed outside production). Raises
|
|
119
|
+
# a 422 otherwise; no-op when unset.
|
|
114
120
|
def validate_ai_endpoint!(base_url_arg, will_have_key)
|
|
115
121
|
return if base_url_arg.blank?
|
|
116
122
|
|
|
@@ -120,9 +126,10 @@ module Labimotion
|
|
|
120
126
|
422
|
|
121
127
|
)
|
|
122
128
|
end
|
|
123
|
-
return if
|
|
129
|
+
return if valid_ai_endpoint?(base_url_arg)
|
|
124
130
|
|
|
125
|
-
|
|
131
|
+
scheme_msg = Rails.env.production? ? 'https://' : 'http:// or https://'
|
|
132
|
+
error!({ status: false, error: "The AI provider endpoint must be a valid #{scheme_msg} URL." }, 422)
|
|
126
133
|
end
|
|
127
134
|
end
|
|
128
135
|
|
|
@@ -136,7 +143,7 @@ module Labimotion
|
|
|
136
143
|
base_url: settings.base_url,
|
|
137
144
|
api_path: settings.api_path,
|
|
138
145
|
available_models: labimotion_ai_models,
|
|
139
|
-
default_model: (configured && configured[:model]).presence,
|
|
146
|
+
default_model: (configured && configured[:model]).presence || DEFAULT_MODELS.first,
|
|
140
147
|
default_base_url: (configured && configured[:base_url]).presence,
|
|
141
148
|
server_api_key_set: (configured && configured[:api_key]).present?
|
|
142
149
|
}
|
|
@@ -69,6 +69,22 @@ module Labimotion
|
|
|
69
69
|
@klass = fetch_klass('ElementKlass', params[:element_klass])
|
|
70
70
|
end
|
|
71
71
|
post do
|
|
72
|
+
# Settle the cheap objections BEFORE spending a provider call on this:
|
|
73
|
+
# a taken name is knowable now, and finding out after generation means
|
|
74
|
+
# 15-90s and a token spend for an answer a lookup could have given.
|
|
75
|
+
validation = Labimotion::AiKlassValidator.error_for(kind: 'segment', params: params, element_klass: @klass)
|
|
76
|
+
next { status: 'error', message: validation } if validation
|
|
77
|
+
|
|
78
|
+
# Generation is a long provider call, so it is handed to the host's worker
|
|
79
|
+
# and the request answers at once; the result arrives as a notification
|
|
80
|
+
# linking back into the designer. A host with no such worker gets the
|
|
81
|
+
# old inline behaviour rather than an error.
|
|
82
|
+
if Labimotion::AiKlassQueue.queue(kind: 'segment', params: params, user: current_user,
|
|
83
|
+
element_klass_id: @klass&.id)
|
|
84
|
+
next { status: 'queued',
|
|
85
|
+
message: 'Generating the template in the background. You will be notified when it is ready.' }
|
|
86
|
+
end
|
|
87
|
+
|
|
72
88
|
msg = create_ai_segment_klass(current_user, params)
|
|
73
89
|
{ status: msg[:status], message: msg[:message],
|
|
74
90
|
klass: Labimotion::SegmentKlassEntity.represent(Labimotion::SegmentKlass.all) }
|
|
@@ -113,9 +113,24 @@ module Labimotion
|
|
|
113
113
|
'created_by' => current_user.id
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
|
|
116
|
+
# Same as the non-AI create beside it: the owner row shares the create's
|
|
117
|
+
# transaction, because a klass committed without one is owner-less and
|
|
118
|
+
# falls OPEN to the legacy designer-wide gate — every designer of the
|
|
119
|
+
# family could then edit, release and delete it, while its creator holds
|
|
120
|
+
# no special standing at all. seed_owner! rescues only RecordNotUnique and
|
|
121
|
+
# RecordInvalid, so any other failure has to take the klass down with it;
|
|
122
|
+
# this helper turns every StandardError into { status: 'error' }, and
|
|
123
|
+
# without the transaction the caller would be told the create failed while
|
|
124
|
+
# an unowned template silently persisted.
|
|
125
|
+
ds = Labimotion::DatasetKlass.transaction do
|
|
126
|
+
Labimotion::DatasetKlass.create!(attributes).tap do |klz|
|
|
127
|
+
Labimotion::KlassShare.seed_owner!(klz, current_user.id)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
117
130
|
ds.create_klasses_revision(current_user)
|
|
118
|
-
|
|
131
|
+
# `record` is the row itself. The background job that calls this needs the
|
|
132
|
+
# template, not a sentence about it, to link the notification back to it.
|
|
133
|
+
{ status: 'success', record: ds,
|
|
119
134
|
message: "The AI dataset template [#{label}] has been created as inactive. Review and activate it in the designer." }
|
|
120
135
|
rescue StandardError => e
|
|
121
136
|
Labimotion.log_exception(e, current_user)
|
|
@@ -106,13 +106,28 @@ module Labimotion
|
|
|
106
106
|
'created_by' => current_user.id
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
|
|
109
|
+
# Same as the non-AI create beside it: the owner row shares the create's
|
|
110
|
+
# transaction, because a klass committed without one is owner-less and
|
|
111
|
+
# falls OPEN to the legacy designer-wide gate — every designer of the
|
|
112
|
+
# family could then edit, release and delete it, while its creator holds
|
|
113
|
+
# no special standing at all. seed_owner! rescues only RecordNotUnique and
|
|
114
|
+
# RecordInvalid, so any other failure has to take the klass down with it;
|
|
115
|
+
# this helper turns every StandardError into { status: 'error' }, and
|
|
116
|
+
# without the transaction the caller would be told the create failed while
|
|
117
|
+
# an unowned template silently persisted.
|
|
118
|
+
new_klass = Labimotion::ElementKlass.transaction do
|
|
119
|
+
Labimotion::ElementKlass.create!(attributes).tap do |klz|
|
|
120
|
+
Labimotion::KlassShare.seed_owner!(klz, current_user.id)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
110
123
|
new_klass.reload
|
|
111
124
|
new_klass.create_klasses_revision(current_user)
|
|
112
125
|
klass_names_file = Labimotion::KLASSES_JSON # Rails.root.join('app/packs/klasses.json')
|
|
113
126
|
klasses = Labimotion::ElementKlass.where(is_active: true)&.pluck(:name) || []
|
|
114
127
|
File.write(klass_names_file, klasses)
|
|
115
|
-
|
|
128
|
+
# `record` is the row itself. The background job that calls this needs the
|
|
129
|
+
# template, not a sentence about it, to link the notification back to it.
|
|
130
|
+
{ status: 'success', record: new_klass,
|
|
116
131
|
message: "The AI element template [#{label}] has been created as inactive. Review and activate it in the designer." }
|
|
117
132
|
rescue StandardError => e
|
|
118
133
|
Labimotion.log_exception(e, current_user)
|
|
@@ -106,10 +106,25 @@ module Labimotion
|
|
|
106
106
|
# params[:metadata] via declared. Absent -> leave the column default.
|
|
107
107
|
attributes['metadata'] = params[:metadata] if params[:metadata].present?
|
|
108
108
|
|
|
109
|
-
|
|
109
|
+
# Same as the non-AI create beside it: the owner row shares the create's
|
|
110
|
+
# transaction, because a klass committed without one is owner-less and
|
|
111
|
+
# falls OPEN to the legacy designer-wide gate — every designer of the
|
|
112
|
+
# family could then edit, release and delete it, while its creator holds
|
|
113
|
+
# no special standing at all. seed_owner! rescues only RecordNotUnique and
|
|
114
|
+
# RecordInvalid, so any other failure has to take the klass down with it;
|
|
115
|
+
# this helper turns every StandardError into { status: 'error' }, and
|
|
116
|
+
# without the transaction the caller would be told the create failed while
|
|
117
|
+
# an unowned template silently persisted.
|
|
118
|
+
klass = Labimotion::SegmentKlass.transaction do
|
|
119
|
+
Labimotion::SegmentKlass.create!(attributes).tap do |klz|
|
|
120
|
+
Labimotion::KlassShare.seed_owner!(klz, current_user.id)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
110
123
|
klass.reload
|
|
111
124
|
klass.create_klasses_revision(current_user)
|
|
112
|
-
|
|
125
|
+
# `record` is the row itself. The background job that calls this needs the
|
|
126
|
+
# template, not a sentence about it, to link the notification back to it.
|
|
127
|
+
{ status: 'success', record: klass,
|
|
113
128
|
message: "The AI segment template [#{params[:label]}] has been created as inactive. Review and activate it in the designer." }
|
|
114
129
|
rescue StandardError => e
|
|
115
130
|
Labimotion.log_exception(e, current_user)
|
|
@@ -26,10 +26,13 @@ module Labimotion
|
|
|
26
26
|
'255.255.255.255/32', '::/128', '::1/128', 'fc00::/7', 'fe80::/10', 'ff00::/8'
|
|
27
27
|
].map { |cidr| IPAddr.new(cidr) }.freeze
|
|
28
28
|
|
|
29
|
-
# Raise BlockedError unless url is a public https endpoint
|
|
29
|
+
# Raise BlockedError unless url is a public https endpoint (http is also
|
|
30
|
+
# accepted outside production, e.g. against a local dev provider). Returns url.
|
|
30
31
|
def self.validate!(url)
|
|
31
32
|
uri = parse(url)
|
|
32
|
-
|
|
33
|
+
allowed_schemes = Rails.env.production? ? %w[https] : %w[https http]
|
|
34
|
+
raise BlockedError, "AI provider URL must use #{allowed_schemes.join(' or ')}" unless
|
|
35
|
+
allowed_schemes.include?(uri.scheme)
|
|
33
36
|
|
|
34
37
|
host = uri.host.to_s
|
|
35
38
|
raise BlockedError, 'AI provider URL has no host' if host.empty?
|
|
@@ -37,7 +40,7 @@ module Labimotion
|
|
|
37
40
|
addresses = resolve(host)
|
|
38
41
|
raise BlockedError, "AI provider host could not be resolved: #{host}" if addresses.empty?
|
|
39
42
|
|
|
40
|
-
blocked = addresses.find { |ip| !
|
|
43
|
+
blocked = addresses.find { |ip| !allowed_ip?(ip) }
|
|
41
44
|
raise BlockedError, "AI provider host resolves to a non-public address (#{blocked})" if blocked
|
|
42
45
|
|
|
43
46
|
url
|
|
@@ -80,5 +83,17 @@ module Labimotion
|
|
|
80
83
|
rescue StandardError
|
|
81
84
|
false
|
|
82
85
|
end
|
|
86
|
+
|
|
87
|
+
# Outside production, a private-network address (e.g. a self-hosted model
|
|
88
|
+
# on another machine on the LAN) is allowed; loopback/link-local/other
|
|
89
|
+
# EXTRA_BLOCKED ranges (cloud metadata etc.) stay blocked regardless of env.
|
|
90
|
+
def self.allowed_ip?(ip)
|
|
91
|
+
return public_ip?(ip) if Rails.env.production?
|
|
92
|
+
return false if ip.loopback? || ip.link_local?
|
|
93
|
+
|
|
94
|
+
EXTRA_BLOCKED.none? { |net| net.include?(ip) }
|
|
95
|
+
rescue StandardError
|
|
96
|
+
false
|
|
97
|
+
end
|
|
83
98
|
end
|
|
84
99
|
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Labimotion
|
|
6
|
+
# Hands AI template generation to the host's background worker.
|
|
7
|
+
#
|
|
8
|
+
# Generating a template is one long provider call — 18s for a small segment,
|
|
9
|
+
# past 90s for a large one — and running it inside the request held the dialog
|
|
10
|
+
# open for all of it, losing the work if the tab was closed or reloaded. The
|
|
11
|
+
# endpoints enqueue instead and answer at once; the result comes back as a
|
|
12
|
+
# notification carrying a link into the designer.
|
|
13
|
+
#
|
|
14
|
+
# The job itself belongs to the host: ActiveJob, the queue and the Message
|
|
15
|
+
# machinery are all its, and a gem that shipped its own would be defining
|
|
16
|
+
# infrastructure it does not own. So the host class is reached by its bare
|
|
17
|
+
# name and only if it exists — the same NameError-guarded pattern used for
|
|
18
|
+
# Matrice and Message elsewhere in this gem. A host without it gets the old
|
|
19
|
+
# inline behaviour rather than an error, which is what `queue` returning false
|
|
20
|
+
# tells the caller.
|
|
21
|
+
module AiKlassQueue
|
|
22
|
+
JOB = 'AiTemplateJob'
|
|
23
|
+
|
|
24
|
+
# @return [Boolean] true when the work was handed off
|
|
25
|
+
def self.queue(kind:, params:, user:, element_klass_id: nil)
|
|
26
|
+
job = job_class
|
|
27
|
+
return false if job.nil?
|
|
28
|
+
|
|
29
|
+
# Grape params are a Hashie::Mash; the queue serialises what it is given,
|
|
30
|
+
# and a Mash carries method_missing behaviour a worker process should not
|
|
31
|
+
# be asked to rebuild. A plain hash is the whole contract.
|
|
32
|
+
args = [kind.to_s, plain(params), user.id, element_klass_id]
|
|
33
|
+
inline? ? run_now(job, args, user) : job.perform_later(*args)
|
|
34
|
+
true
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
# An enqueue that fails must not fail the request: the caller falls back
|
|
37
|
+
# to generating inline, which is slower but still correct.
|
|
38
|
+
Labimotion.log_exception(e, user)
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Queued in every environment, development included: one code path, and the
|
|
43
|
+
# request returns straight away wherever it runs. Development therefore
|
|
44
|
+
# needs a worker like anywhere else — without one the job sits in the table
|
|
45
|
+
# and the notification never arrives.
|
|
46
|
+
#
|
|
47
|
+
# LABIMOTION_AI_INLINE=true runs it in the request instead, for a machine
|
|
48
|
+
# with no worker to spare. Off unless asked for, because a create that
|
|
49
|
+
# blocks for the length of a provider call is not the behaviour anyone
|
|
50
|
+
# should get by accident.
|
|
51
|
+
def self.inline?
|
|
52
|
+
ENV['LABIMOTION_AI_INLINE'] == 'true'
|
|
53
|
+
rescue StandardError
|
|
54
|
+
false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Deliberately swallows: the job reports its own failure as a notification,
|
|
58
|
+
# exactly as it would on a worker, and letting it raise here would send the
|
|
59
|
+
# caller down the inline-generation fallback — spending a second provider
|
|
60
|
+
# call on work that has already been done and paid for.
|
|
61
|
+
def self.run_now(job, args, user)
|
|
62
|
+
job.perform_now(*args)
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
Labimotion.log_exception(e, user)
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.job_class
|
|
69
|
+
Object.const_get(JOB)
|
|
70
|
+
rescue NameError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Only what the helpers read, and stringified: file payloads included, since
|
|
75
|
+
# the reference files are part of the request the worker has to replay.
|
|
76
|
+
#
|
|
77
|
+
# A JSON round trip rather than as_json, because that is precisely what the
|
|
78
|
+
# queue will do to these arguments anyway — so anything that would not
|
|
79
|
+
# survive being written to the jobs table fails here, in the request, rather
|
|
80
|
+
# than in a worker nobody is watching.
|
|
81
|
+
def self.plain(params)
|
|
82
|
+
return {} if params.nil?
|
|
83
|
+
|
|
84
|
+
source = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params
|
|
85
|
+
JSON.parse(JSON.generate(source))
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Labimotion
|
|
4
|
+
# The checks that must happen BEFORE a template is generated.
|
|
5
|
+
#
|
|
6
|
+
# Generation moved to a background job, and the cheap objections — this name
|
|
7
|
+
# is taken, this ontology term already has a template — used to live inside
|
|
8
|
+
# the create helpers, which now run inside that job. So the user spent a
|
|
9
|
+
# provider call and 15-90s of waiting only to be told the label was already in
|
|
10
|
+
# use, which was knowable before a single token was spent. Worse, the answer
|
|
11
|
+
# arrived as a notification long after the dialog that could have fixed it had
|
|
12
|
+
# closed.
|
|
13
|
+
#
|
|
14
|
+
# These are only the objections that can be settled by a lookup. Anything the
|
|
15
|
+
# model decides at create! stays there: this is a courtesy gate, not a second
|
|
16
|
+
# source of truth, and the create still validates for real.
|
|
17
|
+
module AiKlassValidator
|
|
18
|
+
# @return [String, nil] the objection, or nil when there is none
|
|
19
|
+
def self.error_for(kind:, params:, element_klass: nil)
|
|
20
|
+
files = file_error(params)
|
|
21
|
+
return files if files
|
|
22
|
+
|
|
23
|
+
case kind.to_s
|
|
24
|
+
when 'element' then element_error(params)
|
|
25
|
+
when 'segment' then segment_error(params, element_klass)
|
|
26
|
+
when 'dataset' then dataset_error(params)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.file_error(params)
|
|
31
|
+
count = Array(params[:files]).size
|
|
32
|
+
return nil if count <= Labimotion::AiTemplate::MAX_FILES
|
|
33
|
+
|
|
34
|
+
"Too many files (max #{Labimotion::AiTemplate::MAX_FILES})."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Mirrors ElementKlass's `validates :name, uniqueness:` — scoped to rows that
|
|
38
|
+
# are not soft-deleted, so a deleted template does not block the name it no
|
|
39
|
+
# longer holds.
|
|
40
|
+
def self.element_error(params)
|
|
41
|
+
name = params[:name].to_s.strip
|
|
42
|
+
return 'A name is required.' if name.blank?
|
|
43
|
+
return 'A label is required.' if params[:label].to_s.strip.blank?
|
|
44
|
+
|
|
45
|
+
return unless Labimotion::ElementKlass.where(deleted_at: nil).exists?(name: name)
|
|
46
|
+
|
|
47
|
+
"An element named [#{name}] already exists."
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Mirrors SegmentKlass's `validates :label, uniqueness: { scope: :element_klass_id }`.
|
|
51
|
+
# The label only has to be unique WITHIN its parent element, which is why the
|
|
52
|
+
# parent has to be known before this can be answered at all.
|
|
53
|
+
def self.segment_error(params, element_klass)
|
|
54
|
+
label = params[:label].to_s.strip
|
|
55
|
+
return 'A label is required.' if label.blank?
|
|
56
|
+
return 'A parent element is required.' if element_klass.nil?
|
|
57
|
+
|
|
58
|
+
taken = Labimotion::SegmentKlass.where(deleted_at: nil)
|
|
59
|
+
.exists?(label: label, element_klass_id: element_klass.id)
|
|
60
|
+
return unless taken
|
|
61
|
+
|
|
62
|
+
"A segment template labelled [#{label}] already exists for this element."
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.dataset_error(params)
|
|
66
|
+
term = params[:ols_term_id].to_s.split('|').first.to_s.strip
|
|
67
|
+
return 'An ontology term (CHMO) is required.' if term.blank?
|
|
68
|
+
|
|
69
|
+
return unless Labimotion::DatasetKlass.where(deleted_at: nil).exists?(ols_term_id: term)
|
|
70
|
+
|
|
71
|
+
"A dataset template already exists for #{term}. Edit it in the designer instead."
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
data/lib/labimotion/version.rb
CHANGED
data/lib/labimotion.rb
CHANGED
|
@@ -88,6 +88,8 @@ module Labimotion
|
|
|
88
88
|
|
|
89
89
|
######## Libs
|
|
90
90
|
autoload :AiTemplate, 'labimotion/libs/ai_template'
|
|
91
|
+
autoload :AiKlassQueue, 'labimotion/libs/ai_klass_queue'
|
|
92
|
+
autoload :AiKlassValidator, 'labimotion/libs/ai_klass_validator'
|
|
91
93
|
autoload :AiEgressGuard, 'labimotion/libs/ai_egress_guard'
|
|
92
94
|
autoload :AiModels, 'labimotion/libs/ai_models'
|
|
93
95
|
autoload :UserAiSettings, 'labimotion/libs/user_ai_settings'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: labimotion
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.4.0.
|
|
4
|
+
version: 2.4.0.rc11
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chia-Lin Lin
|
|
@@ -9,7 +9,7 @@ authors:
|
|
|
9
9
|
autorequire:
|
|
10
10
|
bindir: bin
|
|
11
11
|
cert_chain: []
|
|
12
|
-
date: 2026-08-
|
|
12
|
+
date: 2026-08-26 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: caxlsx
|
|
@@ -145,6 +145,8 @@ files:
|
|
|
145
145
|
- lib/labimotion/helpers/segment_helpers.rb
|
|
146
146
|
- lib/labimotion/helpers/vocabulary_helpers.rb
|
|
147
147
|
- lib/labimotion/libs/ai_egress_guard.rb
|
|
148
|
+
- lib/labimotion/libs/ai_klass_queue.rb
|
|
149
|
+
- lib/labimotion/libs/ai_klass_validator.rb
|
|
148
150
|
- lib/labimotion/libs/ai_models.rb
|
|
149
151
|
- lib/labimotion/libs/ai_template.rb
|
|
150
152
|
- lib/labimotion/libs/attachment_handler.rb
|