labimotion 2.4.0.rc6 → 2.4.0.rc7
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_element_api.rb +20 -0
- data/lib/labimotion/apis/labimotion_ai_api.rb +239 -0
- data/lib/labimotion/apis/labimotion_api.rb +2 -0
- data/lib/labimotion/apis/ontology_root_api.rb +74 -0
- data/lib/labimotion/conf.rb +5 -0
- data/lib/labimotion/constants.rb +11 -0
- data/lib/labimotion/helpers/element_helpers.rb +51 -1
- data/lib/labimotion/helpers/param_helpers.rb +2 -1
- data/lib/labimotion/libs/ai_models.rb +201 -0
- data/lib/labimotion/libs/ai_template.rb +372 -12
- data/lib/labimotion/libs/ontology_store.rb +226 -0
- data/lib/labimotion/libs/ontology_terms.rb +227 -0
- data/lib/labimotion/models/concerns/profile_ai_settings.rb +116 -0
- data/lib/labimotion/version.rb +1 -1
- data/lib/labimotion.rb +6 -0
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: baacaf6374ee395ea0178ebb5d0ecf6d3fef5a0f37bb17852e4742ac6442ccee
|
|
4
|
+
data.tar.gz: 01ab63f69e28a3d990480c9dd6eb747f26bc7cc727c57ddcb94034a8e987937e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5463d7bffb59f2ef5ef5e664a8ac97fb28557965942a0b182ca2834ec33cc0d81a1ef275799f9b0b7cadb93c1851eff0dbf429c115c863d336c92e91d68395fb
|
|
7
|
+
data.tar.gz: b83156927689da21c02e5a632d526fd1eafb9cd93695ef407fc88cf6b7d65a49593519cb36b2ed9c93afffcb935bbfe3ad274950e549839f6ca40a605a4ec82f
|
|
@@ -232,6 +232,26 @@ module Labimotion
|
|
|
232
232
|
# blocks below, otherwise POST /ai_fill_data falls through to the element
|
|
233
233
|
# instance route and Element.find(nil) is raised.
|
|
234
234
|
namespace :ai_fill_data do
|
|
235
|
+
# Auto-fill is the one AI action ordinary users reach — the designer's
|
|
236
|
+
# create/refine routes are admin-only — so it carries its own whitelist,
|
|
237
|
+
# supplied by the host: the genericElement AI list narrowed by
|
|
238
|
+
# :fill_uids. Reached through the host the same way this API already
|
|
239
|
+
# reaches ElementPolicy, and CLOSED when the host provides no such
|
|
240
|
+
# policy, matching how every other gate in this feature defaults.
|
|
241
|
+
# NameError, not defined?: under Zeitwerk `defined?` is nil until the
|
|
242
|
+
# constant has actually been referenced, so testing it would refuse the
|
|
243
|
+
# first request after every boot. Referencing it triggers the autoload;
|
|
244
|
+
# a host that genuinely ships no such policy raises, and that is a
|
|
245
|
+
# refusal too.
|
|
246
|
+
before do
|
|
247
|
+
allowed = begin
|
|
248
|
+
LabimotionAiAccess.fill_enabled_for?(current_user)
|
|
249
|
+
rescue NameError
|
|
250
|
+
false
|
|
251
|
+
end
|
|
252
|
+
error!({ status: 'error', message: 'AI auto-fill is not enabled for this account.' }, 403) unless allowed
|
|
253
|
+
end
|
|
254
|
+
|
|
235
255
|
desc "Auto-fill a generic element's data values from a document using AI"
|
|
236
256
|
params do
|
|
237
257
|
use :ai_fill_element_data_params
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'labimotion/version'
|
|
4
|
+
|
|
5
|
+
module Labimotion
|
|
6
|
+
# Per-user LabIMotion AI settings: the model, an optional personal API key and
|
|
7
|
+
# an optional provider endpoint of one's own.
|
|
8
|
+
#
|
|
9
|
+
# These live on the HOST's user profile rather than in a gem table, because the
|
|
10
|
+
# credential belongs to the person, not to LabIMotion — so the routes stay
|
|
11
|
+
# under /profiles and reach the host's Profile through current_user, the same
|
|
12
|
+
# way the rest of this gem reaches ElementPolicy and Matrice.
|
|
13
|
+
#
|
|
14
|
+
# The API key is stored encrypted and never returned; a read only reports
|
|
15
|
+
# whether one is set.
|
|
16
|
+
class LabimotionAiAPI < Grape::API
|
|
17
|
+
# Fallback model choices when the server config (config/labimotion_ai.yml
|
|
18
|
+
# :models) lists none. A representative subset of the chat-capable KI-Toolbox
|
|
19
|
+
# models (ki-toolbox.scc.kit.edu); the full list lives in the yml.
|
|
20
|
+
DEFAULT_MODELS = %w[
|
|
21
|
+
azure.gpt-4.1-mini
|
|
22
|
+
azure.gpt-4.1
|
|
23
|
+
azure.gpt-5
|
|
24
|
+
azure.gpt-5-mini
|
|
25
|
+
google.claude-sonnet-4.6
|
|
26
|
+
google.claude-haiku-4.5
|
|
27
|
+
google.gemini-2.5-pro
|
|
28
|
+
kit.gpt-oss-120b
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
# rubocop:disable Metrics/BlockLength
|
|
32
|
+
resource :profiles do
|
|
33
|
+
namespace :labimotion_ai do
|
|
34
|
+
# Per-user LabIMotion AI settings (model + personal API key) for the
|
|
35
|
+
# AI dataset-template feature. The API key is stored encrypted and is
|
|
36
|
+
# never returned — only whether one is set.
|
|
37
|
+
|
|
38
|
+
# These settings only exist to serve the AI integration, so they follow
|
|
39
|
+
# the same whitelist (matrices.configs :ai_uids). Users off the list get
|
|
40
|
+
# a 403 rather than a settings page they cannot act on.
|
|
41
|
+
before do
|
|
42
|
+
error!({ status: false, error: 'AI integration is not enabled for this account.' }, 403) unless
|
|
43
|
+
Matrice.ai_enabled_for_any?(current_user)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
helpers do
|
|
47
|
+
# Normalize a configured model entry (an id String, or a Hash with
|
|
48
|
+
# :id/:label) into { id:, label: }. Label falls back to the id.
|
|
49
|
+
def normalize_ai_model(entry)
|
|
50
|
+
if entry.respond_to?(:[]) && !entry.is_a?(String)
|
|
51
|
+
id = (entry[:id] || entry['id']).to_s
|
|
52
|
+
label = (entry[:label] || entry['label']).to_s
|
|
53
|
+
else
|
|
54
|
+
id = entry.to_s
|
|
55
|
+
label = ''
|
|
56
|
+
end
|
|
57
|
+
return nil if id.blank?
|
|
58
|
+
|
|
59
|
+
{ id: id, label: label.presence || id }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Add id (with a label) to models unless already present. Mutates models.
|
|
63
|
+
def append_ai_model(models, id)
|
|
64
|
+
return if id.blank? || models.any? { |m| m[:id] == id }
|
|
65
|
+
|
|
66
|
+
models << { id: id, label: id }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The admin-approved models [{ id:, label: }] for the SHARED server key:
|
|
70
|
+
# the configured :models pick-list (or the built-in default) plus the
|
|
71
|
+
# server default (:model). No per-user selection — safe to enforce.
|
|
72
|
+
def labimotion_ai_server_models
|
|
73
|
+
configured = Rails.configuration.respond_to?(:labimotion_ai) ? Rails.configuration.labimotion_ai : nil
|
|
74
|
+
models = Array(configured && configured[:models]).filter_map { |e| normalize_ai_model(e) }
|
|
75
|
+
models = Labimotion::LabimotionAiAPI::DEFAULT_MODELS.map { |id| { id: id, label: id } } if models.empty?
|
|
76
|
+
append_ai_model(models, (configured && configured[:model]).to_s)
|
|
77
|
+
models
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The selectable models as [{ id:, label: }]. Extends the server models
|
|
81
|
+
# with the user's saved model so their current selection is always valid.
|
|
82
|
+
def labimotion_ai_models
|
|
83
|
+
models = labimotion_ai_server_models
|
|
84
|
+
append_ai_model(models, current_user.profile.labimotion_ai_model.to_s)
|
|
85
|
+
models
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Model ids the SHARED server key permits — the server models plus the
|
|
89
|
+
# ENV default. Unlike available_models this excludes the user's own saved
|
|
90
|
+
# model, so it is safe to enforce a keyless user's selection against.
|
|
91
|
+
def labimotion_ai_admin_model_ids
|
|
92
|
+
ids = labimotion_ai_server_models.pluck(:id)
|
|
93
|
+
(ids << ENV['KI_TOOLBOX_MODEL'].to_s).compact_blank.uniq
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A well-formed https URL with a host. The deep SSRF check (host resolves
|
|
97
|
+
# to a public address) runs server-side in the gem at request time; this
|
|
98
|
+
# is only a fast format check for immediate UI feedback.
|
|
99
|
+
def https_endpoint?(url)
|
|
100
|
+
uri = URI.parse(url.to_s.strip)
|
|
101
|
+
uri.is_a?(URI::HTTPS) && uri.host.present?
|
|
102
|
+
rescue URI::InvalidURIError
|
|
103
|
+
false
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A personal provider endpoint is only usable with a personal key, and
|
|
107
|
+
# must be a valid https URL. Raises a 422 otherwise; no-op when unset.
|
|
108
|
+
def validate_ai_endpoint!(base_url_arg, will_have_key)
|
|
109
|
+
return if base_url_arg.blank?
|
|
110
|
+
|
|
111
|
+
unless will_have_key
|
|
112
|
+
error!(
|
|
113
|
+
{ status: false, error: 'Add your personal API key to use your own AI provider endpoint.' },
|
|
114
|
+
422
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
return if https_endpoint?(base_url_arg)
|
|
118
|
+
|
|
119
|
+
error!({ status: false, error: 'The AI provider endpoint must be a valid https:// URL.' }, 422)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
desc 'get the current user LabIMotion AI settings (never returns the key)'
|
|
124
|
+
get do
|
|
125
|
+
profile = current_user.profile
|
|
126
|
+
configured = Rails.configuration.respond_to?(:labimotion_ai) ? Rails.configuration.labimotion_ai : nil
|
|
127
|
+
{
|
|
128
|
+
model: profile.labimotion_ai_model,
|
|
129
|
+
api_key_set: profile.labimotion_ai_api_key?,
|
|
130
|
+
base_url: profile.labimotion_ai_base_url,
|
|
131
|
+
api_path: profile.labimotion_ai_api_path,
|
|
132
|
+
available_models: labimotion_ai_models,
|
|
133
|
+
default_model: (configured && configured[:model]).presence,
|
|
134
|
+
default_base_url: (configured && configured[:base_url]).presence,
|
|
135
|
+
server_api_key_set: (configured && configured[:api_key]).present?
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
desc 'update the current user LabIMotion AI settings'
|
|
140
|
+
params do
|
|
141
|
+
optional :model, type: String, desc: 'Selected model id'
|
|
142
|
+
optional :api_key, type: String, desc: 'Personal API key (stored encrypted)'
|
|
143
|
+
optional :clear_api_key, type: Boolean, default: false, desc: 'Remove the stored API key'
|
|
144
|
+
optional :base_url, type: String, desc: 'Personal AI provider base URL (https; needs a personal key)'
|
|
145
|
+
optional :api_path, type: String, desc: 'Personal AI provider chat-completions path'
|
|
146
|
+
optional :clear_endpoint, type: Boolean, default: false, desc: 'Remove the stored provider endpoint'
|
|
147
|
+
end
|
|
148
|
+
put do
|
|
149
|
+
profile = current_user.profile
|
|
150
|
+
api_key_arg =
|
|
151
|
+
if params[:clear_api_key]
|
|
152
|
+
'' # blank -> clear
|
|
153
|
+
elsif params[:api_key].present?
|
|
154
|
+
params[:api_key]
|
|
155
|
+
end
|
|
156
|
+
# nil -> keep the stored key
|
|
157
|
+
|
|
158
|
+
# Whether the user will hold a personal key after this save.
|
|
159
|
+
will_have_key =
|
|
160
|
+
if params[:clear_api_key]
|
|
161
|
+
false
|
|
162
|
+
elsif params[:api_key].present?
|
|
163
|
+
true
|
|
164
|
+
else
|
|
165
|
+
profile.labimotion_ai_api_key?
|
|
166
|
+
end
|
|
167
|
+
# A keyless user's AI calls run on the shared server key, so the chosen
|
|
168
|
+
# model must be one the admin approved. A user on their own key may pick
|
|
169
|
+
# any model. Enforce only when an allowlist is actually configured.
|
|
170
|
+
if params[:model].present? && !will_have_key
|
|
171
|
+
allowed = labimotion_ai_admin_model_ids
|
|
172
|
+
if allowed.any? && allowed.exclude?(params[:model])
|
|
173
|
+
error!(
|
|
174
|
+
{ status: false,
|
|
175
|
+
error: 'That model is not available on the shared server key. Pick a listed ' \
|
|
176
|
+
'model, or add your personal API key in My LabIMotion to use it.' },
|
|
177
|
+
422
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# base_url/api_path: '' clears (via clear_endpoint), a value sets, nil keeps.
|
|
183
|
+
base_url_arg = params[:clear_endpoint] ? '' : params[:base_url]
|
|
184
|
+
api_path_arg = params[:clear_endpoint] ? '' : params[:api_path]
|
|
185
|
+
validate_ai_endpoint!(base_url_arg, will_have_key)
|
|
186
|
+
|
|
187
|
+
summary = profile.update_labimotion_ai(
|
|
188
|
+
model: params[:model].presence, api_key: api_key_arg,
|
|
189
|
+
base_url: base_url_arg, api_path: api_path_arg
|
|
190
|
+
)
|
|
191
|
+
{
|
|
192
|
+
model: summary[:model],
|
|
193
|
+
api_key_set: summary[:api_key_set],
|
|
194
|
+
base_url: summary[:base_url],
|
|
195
|
+
api_path: summary[:api_path],
|
|
196
|
+
available_models: labimotion_ai_models
|
|
197
|
+
}
|
|
198
|
+
rescue StandardError => e
|
|
199
|
+
error!({ status: false, error: e.message }, 422)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
desc 'fetch the live model list from the AI provider with the user personal key'
|
|
203
|
+
post 'models' do
|
|
204
|
+
profile = current_user.profile
|
|
205
|
+
# Deliberately the PERSONAL key only: the shared server key must not be
|
|
206
|
+
# sent to a user-supplied provider URL, and a keyless user's model choice
|
|
207
|
+
# is restricted to labimotion_ai_admin_model_ids anyway.
|
|
208
|
+
result = Labimotion::AiModels.new(
|
|
209
|
+
api_key: profile.labimotion_ai_api_key,
|
|
210
|
+
base_url: profile.labimotion_ai_base_url,
|
|
211
|
+
api_path: profile.labimotion_ai_api_path
|
|
212
|
+
).call
|
|
213
|
+
{ status: true, models: result[:models], endpoint: result[:endpoint] }
|
|
214
|
+
rescue StandardError => e
|
|
215
|
+
error!({ status: false, error: e.message }, 422)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
desc 'test the current user LabIMotion AI connection (nothing is saved)'
|
|
219
|
+
post 'test' do
|
|
220
|
+
profile = current_user.profile
|
|
221
|
+
# Same values the runtime override path reads, so the test exercises the
|
|
222
|
+
# real config: base_url/api_path are honored by the gem only alongside a
|
|
223
|
+
# personal key, and a custom endpoint is SSRF-validated there.
|
|
224
|
+
result = Labimotion::AiTemplate.ping(
|
|
225
|
+
model: profile.labimotion_ai_model,
|
|
226
|
+
api_key: profile.labimotion_ai_api_key,
|
|
227
|
+
base_url: profile.labimotion_ai_base_url,
|
|
228
|
+
api_path: profile.labimotion_ai_api_path
|
|
229
|
+
)
|
|
230
|
+
{ status: true, ok: true, model: result['model'],
|
|
231
|
+
endpoint: result['endpoint'], ms: result['ms'] }
|
|
232
|
+
rescue StandardError => e
|
|
233
|
+
error!({ status: false, ok: false, error: e.message }, 422)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
# rubocop:enable Metrics/BlockLength
|
|
238
|
+
end
|
|
239
|
+
end
|
|
@@ -12,7 +12,9 @@ module Labimotion
|
|
|
12
12
|
mount Labimotion::LabimotionHubAPI
|
|
13
13
|
mount Labimotion::StandardLayerAPI
|
|
14
14
|
mount Labimotion::VocabularyAPI
|
|
15
|
+
mount Labimotion::OntologyRootAPI
|
|
15
16
|
mount Labimotion::UserAPI
|
|
17
|
+
mount Labimotion::LabimotionAiAPI
|
|
16
18
|
mount Labimotion::UserKlassSettingsAPI
|
|
17
19
|
mount Labimotion::KlassShareAPI
|
|
18
20
|
mount Labimotion::MttAPI
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'labimotion/version'
|
|
4
|
+
|
|
5
|
+
module Labimotion
|
|
6
|
+
# Serves the stored subtree behind one "ontology-select" root, and lets a user
|
|
7
|
+
# refresh it.
|
|
8
|
+
#
|
|
9
|
+
# The client sends the `ontology_term` string its field already holds and the
|
|
10
|
+
# server derives the file path from it. Nothing about the naming scheme is
|
|
11
|
+
# public, so it can change later without touching the frontend or migrating
|
|
12
|
+
# anything — worst case the files are fetched again.
|
|
13
|
+
#
|
|
14
|
+
# One root per request. A field may pin several, and the picker already walks
|
|
15
|
+
# them one at a time.
|
|
16
|
+
class OntologyRootAPI < Grape::API
|
|
17
|
+
helpers Labimotion::GenericHelpers
|
|
18
|
+
|
|
19
|
+
resource :ontology_roots do
|
|
20
|
+
helpers do
|
|
21
|
+
# The one root a request is about, or a 400 naming what was unusable.
|
|
22
|
+
def ontology_root!(term)
|
|
23
|
+
root = Labimotion::OntologyTerms.parse(term).first
|
|
24
|
+
error!({ status: 'error', message: 'not a usable ontology term' }, 400) if root.nil?
|
|
25
|
+
|
|
26
|
+
root
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def refresher
|
|
30
|
+
current_user.respond_to?(:name_abbreviation) ? current_user.name_abbreviation : nil
|
|
31
|
+
rescue StandardError
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
desc "One root's subtree, as the picker and the auto-fill both read it"
|
|
37
|
+
params do
|
|
38
|
+
requires :term, type: String, desc: "The field's ontology_term value"
|
|
39
|
+
end
|
|
40
|
+
get do
|
|
41
|
+
root = ontology_root!(params[:term])
|
|
42
|
+
doc = Labimotion::OntologyStore.document(root, synced_by: refresher)
|
|
43
|
+
if doc.nil?
|
|
44
|
+
# The store could not be filled and the service could not be reached.
|
|
45
|
+
# The picker falls back to what it can, rather than showing an empty
|
|
46
|
+
# vocabulary as though the ontology genuinely had no terms.
|
|
47
|
+
{ status: 'error', message: 'this ontology could not be reached' }
|
|
48
|
+
else
|
|
49
|
+
{ status: 'success', root: doc }
|
|
50
|
+
end
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
Labimotion.log_exception(e, current_user)
|
|
53
|
+
{ status: 'error', message: e.message }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
desc 'Re-fetch this root from the ontology service and store what comes back'
|
|
57
|
+
params do
|
|
58
|
+
requires :term, type: String, desc: "The field's ontology_term value"
|
|
59
|
+
end
|
|
60
|
+
post :refresh do
|
|
61
|
+
root = ontology_root!(params[:term])
|
|
62
|
+
{ status: 'success', root: Labimotion::OntologyStore.refresh!(root, synced_by: refresher) }
|
|
63
|
+
rescue Labimotion::OntologyStore::Error => e
|
|
64
|
+
# Expected refusals — already refreshed a moment ago, someone else is
|
|
65
|
+
# refreshing, the result came back empty or collapsed. The person who
|
|
66
|
+
# pressed the button gets the reason; what is stored is untouched.
|
|
67
|
+
{ status: 'error', message: e.message }
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
Labimotion.log_exception(e, current_user)
|
|
70
|
+
{ status: 'error', message: e.message }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
data/lib/labimotion/conf.rb
CHANGED
|
@@ -3,4 +3,9 @@
|
|
|
3
3
|
## Labimotion Configuration
|
|
4
4
|
module Labimotion
|
|
5
5
|
KLASSES_JSON = Rails.root.join('public', 'klasses.json').to_s.freeze # Rails.root.join('app/packs/klasses.json').to_s.freeze
|
|
6
|
+
|
|
7
|
+
# One JSON file per ontology root, holding the subtree an "ontology-select"
|
|
8
|
+
# field may offer. Sits under the host's public/ontologies, which the ELN
|
|
9
|
+
# already gitignores and already lets this gem write to (see KLASSES_JSON).
|
|
10
|
+
ONTOLOGY_ROOTS_DIR = Rails.public_path.join('ontologies', 'roots').to_s.freeze
|
|
6
11
|
end
|
data/lib/labimotion/constants.rb
CHANGED
|
@@ -51,6 +51,17 @@ module Labimotion
|
|
|
51
51
|
LINKED_EL_ATTRS = 'linked_el_attrs'
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# The independent sub-settings held side by side in a KLASS's own `settings`
|
|
55
|
+
# jsonb (element_klasses / segment_klasses / dataset_klasses). A save
|
|
56
|
+
# shallow-merges, so writing one never disturbs the others.
|
|
57
|
+
module KlassSetting
|
|
58
|
+
LAYER_FUNCTIONS = 'layer_functions'
|
|
59
|
+
COVER_IMAGE = 'cover_image'
|
|
60
|
+
# Template-specific guidance for the AI data auto-fill, written by the
|
|
61
|
+
# designer and read by Labimotion::AiTemplate.fill.
|
|
62
|
+
AI_FILL_PROMPT = 'ai_fill_prompt'
|
|
63
|
+
end
|
|
64
|
+
|
|
54
65
|
# The designer-right families as keyed in `users.generic_admin` (host-owned jsonb) and
|
|
55
66
|
# read by authenticate_admin!. FAMILY_OF answers which family gates a klass type.
|
|
56
67
|
module Family
|
|
@@ -136,8 +136,9 @@ module Labimotion
|
|
|
136
136
|
text = ai_fill_source_text(element, params)
|
|
137
137
|
overrides = ai_user_overrides(current_user)
|
|
138
138
|
ai = Labimotion::AiTemplate.fill(
|
|
139
|
-
properties: element
|
|
139
|
+
properties: ai_fill_properties(element),
|
|
140
140
|
context_text: text,
|
|
141
|
+
instructions: ai_fill_instructions(element),
|
|
141
142
|
**overrides
|
|
142
143
|
)
|
|
143
144
|
{ status: 'success', values: ai['values'], summary: ai['summary'] }
|
|
@@ -146,6 +147,43 @@ module Labimotion
|
|
|
146
147
|
{ status: 'error', message: e.message }
|
|
147
148
|
end
|
|
148
149
|
|
|
150
|
+
# The element's properties, with the select_options its own fields point at.
|
|
151
|
+
#
|
|
152
|
+
# An element instance never stores select_options: create and update both
|
|
153
|
+
# delete the key (see create_element/update_element below), because the
|
|
154
|
+
# options belong to the template, not to the instance. The fill prompt lists
|
|
155
|
+
# a select's allowed options so the model can pick one, and reads them out
|
|
156
|
+
# of the properties it is handed — so passing element.properties straight
|
|
157
|
+
# through offered the model an EMPTY list for every select and select-multi
|
|
158
|
+
# field. Nothing to choose from, so those fields came back unfilled, which
|
|
159
|
+
# reads as the AI having ignored them.
|
|
160
|
+
#
|
|
161
|
+
# properties_release, not the klass's current properties_template: it is the
|
|
162
|
+
# release this element was actually built against, so its option keys are the
|
|
163
|
+
# ones the element's fields reference and the ones the form will accept back.
|
|
164
|
+
# Same source the UI and the exporter read for this element.
|
|
165
|
+
def ai_fill_properties(element)
|
|
166
|
+
props = element.properties
|
|
167
|
+
return props unless props.is_a?(Hash)
|
|
168
|
+
return props if props['select_options'].present?
|
|
169
|
+
|
|
170
|
+
release = element.properties_release
|
|
171
|
+
options = release.is_a?(Hash) ? release['select_options'] : nil
|
|
172
|
+
return props if options.blank?
|
|
173
|
+
|
|
174
|
+
props.merge('select_options' => options)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The template's own auto-fill guidance, from Template settings in the designer.
|
|
178
|
+
# Read from the KLASS rather than the element's copied properties, so editing it
|
|
179
|
+
# takes effect on elements that already exist instead of only on new ones.
|
|
180
|
+
def ai_fill_instructions(element)
|
|
181
|
+
settings = element.element_klass&.settings
|
|
182
|
+
return nil unless settings.is_a?(Hash)
|
|
183
|
+
|
|
184
|
+
settings[Labimotion::Constants::KlassSetting::AI_FILL_PROMPT].presence
|
|
185
|
+
end
|
|
186
|
+
|
|
149
187
|
# Resolve the requested source into plain text, enforcing ownership first.
|
|
150
188
|
def ai_fill_source_text(element, params)
|
|
151
189
|
case params[:source]
|
|
@@ -155,6 +193,8 @@ module Labimotion
|
|
|
155
193
|
ai_fill_analysis_text(element, params[:container_id])
|
|
156
194
|
when 'upload'
|
|
157
195
|
ai_fill_upload_text(params[:files])
|
|
196
|
+
when 'text'
|
|
197
|
+
ai_fill_pasted_text(params[:text])
|
|
158
198
|
else
|
|
159
199
|
error!('400 Bad Request', 400)
|
|
160
200
|
end
|
|
@@ -205,6 +245,16 @@ module Labimotion
|
|
|
205
245
|
parts.join("\n\n")
|
|
206
246
|
end
|
|
207
247
|
|
|
248
|
+
# Text pasted straight into the dialog. Capped like an extracted file so a
|
|
249
|
+
# very long paste cannot blow the model's context or the request budget —
|
|
250
|
+
# the same MAX_FILE_CHARS the file path is held to.
|
|
251
|
+
def ai_fill_pasted_text(text)
|
|
252
|
+
body = text.to_s.strip
|
|
253
|
+
error!('400 Bad Request', 400) if body.empty?
|
|
254
|
+
|
|
255
|
+
body[0, Labimotion::AiTemplate::MAX_FILE_CHARS].to_s
|
|
256
|
+
end
|
|
257
|
+
|
|
208
258
|
# Ids of attachments this element may expose to the AI fill: its own
|
|
209
259
|
# attachments plus the attachments of its analysis dataset containers.
|
|
210
260
|
def ai_fill_element_attachment_ids(element)
|
|
@@ -66,9 +66,10 @@ module Labimotion
|
|
|
66
66
|
# `files` mirrors the create_ai_*_klass_params file block (source == 'upload').
|
|
67
67
|
params :ai_fill_element_data_params do
|
|
68
68
|
requires :element_id, type: Integer, desc: 'Generic element id whose data values to fill'
|
|
69
|
-
requires :source, type: String, values: %w[attachment analysis upload], desc: 'Value source'
|
|
69
|
+
requires :source, type: String, values: %w[attachment analysis upload text], desc: 'Value source'
|
|
70
70
|
optional :attachment_id, type: Integer, desc: 'Attachment id (required when source == attachment)'
|
|
71
71
|
optional :container_id, type: Integer, desc: 'Analysis container id (required when source == analysis)'
|
|
72
|
+
optional :text, type: String, desc: 'Pasted plain text (source == text)'
|
|
72
73
|
optional :files, type: Array, desc: 'Uploaded files (source == upload)' do
|
|
73
74
|
optional :filename, type: String, desc: 'File name'
|
|
74
75
|
optional :content, type: String, desc: 'Client-read plaintext (legacy, truncated)'
|