dexkit 0.9.0 → 0.11.0
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/CHANGELOG.md +57 -1
- data/README.md +63 -254
- data/gemfiles/mongoid_no_ar.gemfile.lock +2 -2
- data/guides/llm/EVENT.md +25 -26
- data/guides/llm/FORM.md +200 -59
- data/guides/llm/OPERATION.md +115 -57
- data/guides/llm/QUERY.md +56 -0
- data/guides/llm/TOOL.md +308 -0
- data/lib/dex/context_dsl.rb +56 -0
- data/lib/dex/context_setup.rb +2 -33
- data/lib/dex/event/bus.rb +79 -11
- data/lib/dex/event/handler.rb +18 -1
- data/lib/dex/event/metadata.rb +15 -20
- data/lib/dex/event/processor.rb +2 -16
- data/lib/dex/event/test_helpers.rb +1 -1
- data/lib/dex/event.rb +3 -10
- data/lib/dex/form/context.rb +27 -0
- data/lib/dex/form/export.rb +128 -0
- data/lib/dex/form/nesting.rb +2 -0
- data/lib/dex/form.rb +119 -3
- data/lib/dex/id.rb +125 -0
- data/lib/dex/operation/async_proxy.rb +22 -4
- data/lib/dex/operation/guard_wrapper.rb +1 -1
- data/lib/dex/operation/jobs.rb +5 -4
- data/lib/dex/operation/once_wrapper.rb +1 -0
- data/lib/dex/operation/outcome.rb +14 -0
- data/lib/dex/operation/record_backend.rb +2 -1
- data/lib/dex/operation/record_wrapper.rb +14 -4
- data/lib/dex/operation/result_wrapper.rb +0 -12
- data/lib/dex/operation/test_helpers/assertions.rb +0 -88
- data/lib/dex/operation/test_helpers.rb +11 -1
- data/lib/dex/operation/ticket.rb +268 -0
- data/lib/dex/operation/trace_wrapper.rb +20 -0
- data/lib/dex/operation.rb +3 -0
- data/lib/dex/operation_failed.rb +14 -0
- data/lib/dex/query/export.rb +64 -0
- data/lib/dex/query.rb +41 -0
- data/lib/dex/test_log.rb +62 -4
- data/lib/dex/timeout.rb +14 -0
- data/lib/dex/tool.rb +388 -5
- data/lib/dex/trace.rb +291 -0
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +22 -3
- metadata +12 -3
- data/lib/dex/event/trace.rb +0 -56
- data/lib/dex/event_test_helpers.rb +0 -3
data/lib/dex/query.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "active_model"
|
|
4
4
|
|
|
5
5
|
require_relative "query/backend"
|
|
6
|
+
require_relative "query/export"
|
|
6
7
|
require_relative "query/filtering"
|
|
7
8
|
require_relative "query/sorting"
|
|
8
9
|
|
|
@@ -17,7 +18,11 @@ module Dex
|
|
|
17
18
|
extend ActiveModel::Naming
|
|
18
19
|
include ActiveModel::Conversion
|
|
19
20
|
|
|
21
|
+
extend Registry
|
|
22
|
+
|
|
20
23
|
class << self
|
|
24
|
+
include ContextDSL
|
|
25
|
+
|
|
21
26
|
def scope(&block)
|
|
22
27
|
raise ArgumentError, "scope requires a block." unless block
|
|
23
28
|
|
|
@@ -31,6 +36,14 @@ module Dex
|
|
|
31
36
|
end
|
|
32
37
|
|
|
33
38
|
def new(scope: nil, sort: nil, **kwargs)
|
|
39
|
+
mappings = context_mappings
|
|
40
|
+
unless mappings.empty?
|
|
41
|
+
ambient = Dex.context
|
|
42
|
+
mappings.each do |prop_name, context_key|
|
|
43
|
+
next if kwargs.key?(prop_name)
|
|
44
|
+
kwargs[prop_name] = ambient[context_key] if ambient.key?(context_key)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
34
47
|
instance = super(**kwargs)
|
|
35
48
|
instance.instance_variable_set(:@_injected_scope, scope)
|
|
36
49
|
sort_str = sort&.to_s
|
|
@@ -96,6 +109,30 @@ module Dex
|
|
|
96
109
|
subclass.instance_variable_set(:@_sort_default, _sort_default) if _sort_default
|
|
97
110
|
end
|
|
98
111
|
|
|
112
|
+
# Export
|
|
113
|
+
|
|
114
|
+
def to_h
|
|
115
|
+
Export.build_hash(self)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def to_json_schema
|
|
119
|
+
Export.build_json_schema(self)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def export(format: :hash)
|
|
123
|
+
unless %i[hash json_schema].include?(format)
|
|
124
|
+
raise ArgumentError, "unknown format: #{format.inspect}. Known: :hash, :json_schema"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
sorted = registry.sort_by(&:name)
|
|
128
|
+
sorted.map do |klass|
|
|
129
|
+
case format
|
|
130
|
+
when :hash then klass.to_h
|
|
131
|
+
when :json_schema then klass.to_json_schema
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
99
136
|
def from_params(params, scope: nil, **overrides)
|
|
100
137
|
pk = model_name.param_key
|
|
101
138
|
nested = _extract_nested_params(params, pk)
|
|
@@ -139,6 +176,10 @@ module Dex
|
|
|
139
176
|
|
|
140
177
|
private
|
|
141
178
|
|
|
179
|
+
def _context_prop_declared?(name)
|
|
180
|
+
respond_to?(:literal_properties) && literal_properties.any? { |p| p.name == name }
|
|
181
|
+
end
|
|
182
|
+
|
|
142
183
|
def _extract_nested_params(params, pk)
|
|
143
184
|
hash = if params.respond_to?(:to_unsafe_h)
|
|
144
185
|
params.to_unsafe_h
|
data/lib/dex/test_log.rb
CHANGED
|
@@ -2,7 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
module Dex
|
|
4
4
|
module TestLog
|
|
5
|
-
Entry = Data.define(
|
|
5
|
+
Entry = Data.define(
|
|
6
|
+
:type,
|
|
7
|
+
:name,
|
|
8
|
+
:operation_class,
|
|
9
|
+
:params,
|
|
10
|
+
:result,
|
|
11
|
+
:duration,
|
|
12
|
+
:caller_location,
|
|
13
|
+
:execution_id,
|
|
14
|
+
:trace_id,
|
|
15
|
+
:trace
|
|
16
|
+
)
|
|
6
17
|
|
|
7
18
|
@_entries = []
|
|
8
19
|
@_mutex = Mutex.new
|
|
@@ -42,14 +53,61 @@ module Dex
|
|
|
42
53
|
return "No operations called." if entries.empty?
|
|
43
54
|
|
|
44
55
|
lines = ["Operations called (#{entries.size}):"]
|
|
45
|
-
entries.each_with_index
|
|
56
|
+
nodes = entries.each_with_index.map { |entry, index| { entry: entry, index: index } }
|
|
57
|
+
by_id = nodes.to_h { |node| [node[:entry].execution_id, node] }
|
|
58
|
+
children = Hash.new { |hash, key| hash[key] = [] }
|
|
59
|
+
roots = []
|
|
60
|
+
|
|
61
|
+
nodes.each do |node|
|
|
62
|
+
parent_id = _parent_operation_id(node[:entry])
|
|
63
|
+
if parent_id && by_id[parent_id]
|
|
64
|
+
children[parent_id] << node
|
|
65
|
+
else
|
|
66
|
+
roots << node
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
counter = 0
|
|
71
|
+
render = lambda do |node, depth|
|
|
72
|
+
counter += 1
|
|
73
|
+
entry = node[:entry]
|
|
46
74
|
status = entry.result.ok? ? "OK" : "ERR(#{entry.result.code})"
|
|
47
75
|
duration_ms = entry.duration ? format("%.1fms", entry.duration * 1000) : "n/a"
|
|
48
|
-
|
|
49
|
-
|
|
76
|
+
indent = " " * depth
|
|
77
|
+
id = entry.execution_id ? " (#{_display_id(entry.execution_id)})" : ""
|
|
78
|
+
|
|
79
|
+
lines << " #{indent}#{counter}. #{entry.name}#{id} [#{status}] #{duration_ms}"
|
|
80
|
+
lines << " #{indent} params: #{entry.params.inspect}" unless entry.params.nil? || entry.params.empty?
|
|
81
|
+
|
|
82
|
+
children.fetch(entry.execution_id, []).sort_by { |child| child[:index] }.each do |child|
|
|
83
|
+
render.call(child, depth + 1)
|
|
84
|
+
end
|
|
50
85
|
end
|
|
86
|
+
|
|
87
|
+
roots.sort_by { |node| node[:index] }.each { |node| render.call(node, 0) }
|
|
51
88
|
lines.join("\n")
|
|
52
89
|
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def _frame_type(frame)
|
|
94
|
+
return unless frame
|
|
95
|
+
|
|
96
|
+
(frame[:type] || frame["type"])&.to_sym
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def _display_id(id)
|
|
100
|
+
prefix, suffix = id.to_s.split("_", 2)
|
|
101
|
+
return id.to_s unless suffix
|
|
102
|
+
|
|
103
|
+
"#{prefix}_#{suffix[0, 7]}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def _parent_operation_id(entry)
|
|
107
|
+
frames = Array(entry.trace)[0...-1]
|
|
108
|
+
parent = frames.reverse.find { |frame| _frame_type(frame) == :operation }
|
|
109
|
+
parent && (parent[:id] || parent["id"])
|
|
110
|
+
end
|
|
53
111
|
end
|
|
54
112
|
end
|
|
55
113
|
end
|
data/lib/dex/timeout.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Timeout < StandardError
|
|
5
|
+
attr_reader :timeout, :ticket_id, :operation_name
|
|
6
|
+
|
|
7
|
+
def initialize(timeout:, ticket_id:, operation_name:)
|
|
8
|
+
@timeout = timeout.to_f
|
|
9
|
+
@ticket_id = ticket_id
|
|
10
|
+
@operation_name = operation_name
|
|
11
|
+
super("#{operation_name} did not complete within #{@timeout}s (ticket: #{ticket_id})")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/dex/tool.rb
CHANGED
|
@@ -2,11 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
module Dex
|
|
4
4
|
module Tool
|
|
5
|
+
QUERY_TOOL_OPTIONS = %i[scope serialize limit only_filters except_filters only_sorts].freeze
|
|
6
|
+
|
|
5
7
|
module_function
|
|
6
8
|
|
|
7
|
-
def from(
|
|
9
|
+
def from(klass, **opts)
|
|
8
10
|
_require_ruby_llm!
|
|
9
|
-
|
|
11
|
+
|
|
12
|
+
if klass < Dex::Query
|
|
13
|
+
_validate_query_tool_options!(klass, opts)
|
|
14
|
+
_build_query_tool(klass, **opts)
|
|
15
|
+
elsif klass < Dex::Operation
|
|
16
|
+
_reject_unknown_options!(klass, opts)
|
|
17
|
+
_build_tool(klass)
|
|
18
|
+
else
|
|
19
|
+
raise ArgumentError, "expected a Dex::Operation or Dex::Query subclass, got #{klass}"
|
|
20
|
+
end
|
|
10
21
|
end
|
|
11
22
|
|
|
12
23
|
def all
|
|
@@ -35,7 +46,9 @@ module Dex
|
|
|
35
46
|
"Dex::Tool requires the ruby-llm gem. Add `gem 'ruby_llm'` to your Gemfile."
|
|
36
47
|
end
|
|
37
48
|
|
|
38
|
-
|
|
49
|
+
# --- Operation tools ---
|
|
50
|
+
|
|
51
|
+
def _build_tool(operation_class)
|
|
39
52
|
op = operation_class
|
|
40
53
|
schema = op.contract.to_json_schema
|
|
41
54
|
tool_description = _tool_description(op)
|
|
@@ -77,7 +90,374 @@ module Dex
|
|
|
77
90
|
parts.join("\n")
|
|
78
91
|
end
|
|
79
92
|
|
|
80
|
-
def
|
|
93
|
+
def _reject_unknown_options!(_klass, opts)
|
|
94
|
+
return if opts.empty?
|
|
95
|
+
|
|
96
|
+
raise ArgumentError, "#{opts.keys.first}: is not a valid option for Operation tools"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# --- Query tools ---
|
|
100
|
+
|
|
101
|
+
def _validate_query_tool_options!(klass, opts)
|
|
102
|
+
unknown = opts.keys - QUERY_TOOL_OPTIONS
|
|
103
|
+
unless unknown.empty?
|
|
104
|
+
raise ArgumentError, "unknown option#{"s" if unknown.size > 1}: #{unknown.map { |k| "#{k}:" }.join(", ")}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
label = klass.name || klass.inspect
|
|
108
|
+
|
|
109
|
+
unless opts.key?(:scope)
|
|
110
|
+
raise ArgumentError, "#{label} is a Dex::Query. Query tools require scope: and serialize:.\n" \
|
|
111
|
+
"Example: Dex::Tool.from(#{label},\n" \
|
|
112
|
+
" scope: -> { Current.user.orders },\n" \
|
|
113
|
+
" serialize: ->(record) { record.as_json(only: [:id, :name]) })"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
unless opts.key?(:serialize)
|
|
117
|
+
raise ArgumentError, "#{label} is a Dex::Query. Query tools require serialize:.\n" \
|
|
118
|
+
"Example: Dex::Tool.from(#{label},\n" \
|
|
119
|
+
" scope: -> { Current.user.orders },\n" \
|
|
120
|
+
" serialize: ->(record) { record.as_json(only: [:id, :name]) })"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
unless klass._scope_block
|
|
124
|
+
raise ArgumentError, "#{label} has no scope block. Define scope { ... } in the query class."
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
unless opts[:scope].respond_to?(:call)
|
|
128
|
+
raise ArgumentError, "scope: must respond to call (lambda or proc)"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
unless opts[:serialize].respond_to?(:call)
|
|
132
|
+
raise ArgumentError, "serialize: must respond to call (lambda or proc)"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
if opts.key?(:limit)
|
|
136
|
+
unless opts[:limit].is_a?(Integer) && opts[:limit] > 0
|
|
137
|
+
raise ArgumentError, "limit: must be a positive integer"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
if opts.key?(:only_filters) && opts.key?(:except_filters)
|
|
142
|
+
raise ArgumentError, "only_filters: and except_filters: are mutually exclusive"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
declared_filters = klass.filters
|
|
146
|
+
if opts.key?(:only_filters)
|
|
147
|
+
context_mapped = klass.context_mappings.keys.to_set
|
|
148
|
+
opts[:only_filters].each do |f|
|
|
149
|
+
f_sym = f.to_sym
|
|
150
|
+
unless declared_filters.include?(f_sym)
|
|
151
|
+
raise ArgumentError, "unknown filter :#{f}. Declared: #{declared_filters.inspect}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
if context_mapped.include?(f_sym)
|
|
155
|
+
raise ArgumentError, "filter :#{f} is context-mapped and automatically excluded from " \
|
|
156
|
+
"tool schema. Remove it from only_filters:"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
prop = klass.literal_properties.find { |p| p.name == f_sym }
|
|
160
|
+
if prop && _ref_type?(prop.type)
|
|
161
|
+
raise ArgumentError, "filter :#{f} backs a _Ref prop and is automatically excluded from " \
|
|
162
|
+
"tool schema. Remove it from only_filters:"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
if opts.key?(:except_filters)
|
|
168
|
+
opts[:except_filters].each do |f|
|
|
169
|
+
unless declared_filters.include?(f.to_sym)
|
|
170
|
+
raise ArgumentError, "unknown filter :#{f} in except_filters. Declared: #{declared_filters.inspect}"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
declared_sorts = klass.sorts
|
|
176
|
+
if opts.key?(:only_sorts)
|
|
177
|
+
opts[:only_sorts].each do |s|
|
|
178
|
+
unless declared_sorts.include?(s.to_sym)
|
|
179
|
+
raise ArgumentError, "unknown sort :#{s}. Declared: #{declared_sorts.inspect}"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
default_sort = klass._sort_default
|
|
184
|
+
if default_sort
|
|
185
|
+
bare = default_sort.delete_prefix("-").to_sym
|
|
186
|
+
unless opts[:only_sorts].map(&:to_sym).include?(bare)
|
|
187
|
+
raise ArgumentError, "query default sort #{default_sort} is not in only_sorts. " \
|
|
188
|
+
"Include it or change the query's default"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
if klass.respond_to?(:literal_properties)
|
|
194
|
+
klass.literal_properties.each do |prop|
|
|
195
|
+
if %i[limit offset].include?(prop.name)
|
|
196
|
+
raise ArgumentError, "#{label} declares prop :#{prop.name} which conflicts with " \
|
|
197
|
+
"the tool's pagination parameter. Rename the prop"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
_validate_satisfiable_props!(klass, opts)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def _validate_satisfiable_props!(klass, opts)
|
|
206
|
+
return unless klass.respond_to?(:literal_properties)
|
|
207
|
+
|
|
208
|
+
context_keys = klass.context_mappings.keys.to_set
|
|
209
|
+
visible = _compute_visible_filters(klass, opts[:only_filters], opts[:except_filters])
|
|
210
|
+
excluded = _query_excluded_prop_names(klass, visible)
|
|
211
|
+
|
|
212
|
+
klass.literal_properties.each do |prop|
|
|
213
|
+
next unless excluded.include?(prop.name)
|
|
214
|
+
next unless prop.required?
|
|
215
|
+
next if context_keys.include?(prop.name)
|
|
216
|
+
|
|
217
|
+
if _ref_type?(prop.type)
|
|
218
|
+
raise ArgumentError, "prop :#{prop.name} (_Ref) is auto-excluded from the tool schema " \
|
|
219
|
+
"but is required with no default and no context mapping — the tool could never execute"
|
|
220
|
+
else
|
|
221
|
+
raise ArgumentError, "excluding filter :#{prop.name} hides required prop :#{prop.name} " \
|
|
222
|
+
"which has no default and no context mapping — the tool could never execute"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def _build_query_tool(klass, scope:, serialize:, limit: 50, only_filters: nil, except_filters: nil, only_sorts: nil)
|
|
228
|
+
max_limit = limit
|
|
229
|
+
scope_lambda = scope
|
|
230
|
+
serialize_lambda = serialize
|
|
231
|
+
|
|
232
|
+
visible_filters = _compute_visible_filters(klass, only_filters, except_filters)
|
|
233
|
+
allowed_sorts = only_sorts ? only_sorts.map(&:to_sym) : klass.sorts
|
|
234
|
+
|
|
235
|
+
schema = _query_params_schema(klass, visible_filters, allowed_sorts, max_limit)
|
|
236
|
+
tool_desc = _query_tool_description(klass, visible_filters, allowed_sorts, max_limit)
|
|
237
|
+
tool_name = "dex_query_#{(klass.name || "query").gsub("::", "_").downcase}"
|
|
238
|
+
|
|
239
|
+
stripped_param_keys = klass.context_mappings.keys.to_set
|
|
240
|
+
invisible_filters = klass.filters.to_set - visible_filters.to_set
|
|
241
|
+
stripped_param_keys.merge(invisible_filters)
|
|
242
|
+
|
|
243
|
+
query_class = klass
|
|
244
|
+
|
|
245
|
+
Class.new(RubyLLM::Tool) do
|
|
246
|
+
define_method(:name) { tool_name }
|
|
247
|
+
define_method(:description) { tool_desc }
|
|
248
|
+
define_method(:params_schema) { schema }
|
|
249
|
+
|
|
250
|
+
define_method(:execute) do |**params|
|
|
251
|
+
params = params.transform_keys(&:to_sym)
|
|
252
|
+
|
|
253
|
+
req_limit = [(params.delete(:limit) || max_limit).to_i, max_limit].min
|
|
254
|
+
req_limit = max_limit if req_limit <= 0
|
|
255
|
+
req_offset = [params.delete(:offset).to_i, 0].max
|
|
256
|
+
|
|
257
|
+
sort_value = params.delete(:sort)&.to_s
|
|
258
|
+
if sort_value && !sort_value.empty?
|
|
259
|
+
bare = sort_value.delete_prefix("-").to_sym
|
|
260
|
+
valid = allowed_sorts.include?(bare)
|
|
261
|
+
if valid && sort_value.start_with?("-")
|
|
262
|
+
sort_def = query_class._sort_registry[bare]
|
|
263
|
+
valid = false if sort_def&.custom
|
|
264
|
+
end
|
|
265
|
+
sort_value = nil unless valid
|
|
266
|
+
else
|
|
267
|
+
sort_value = nil
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
stripped_param_keys.each { |k| params.delete(k) }
|
|
271
|
+
|
|
272
|
+
injected_scope = scope_lambda.call
|
|
273
|
+
|
|
274
|
+
overrides = {}
|
|
275
|
+
overrides[:sort] = sort_value if sort_value
|
|
276
|
+
query = query_class.from_params(params, scope: injected_scope, **overrides)
|
|
277
|
+
|
|
278
|
+
records = query.resolve
|
|
279
|
+
|
|
280
|
+
total = begin
|
|
281
|
+
result = records.count
|
|
282
|
+
result.is_a?(Integer) ? result : nil
|
|
283
|
+
rescue
|
|
284
|
+
nil
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
records = records.offset(req_offset).limit(req_limit)
|
|
288
|
+
|
|
289
|
+
serialized = records.map { |r| serialize_lambda.call(r) }
|
|
290
|
+
|
|
291
|
+
{ records: serialized, total: total, limit: req_limit, offset: req_offset }
|
|
292
|
+
rescue ArgumentError, Literal::TypeError => e
|
|
293
|
+
{ error: "invalid_params", message: e.message }
|
|
294
|
+
rescue => e
|
|
295
|
+
{ error: "query_failed", message: e.message }
|
|
296
|
+
end
|
|
297
|
+
end.new
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def _query_tool_description(klass, visible_filters, allowed_sorts, max_limit)
|
|
301
|
+
parts = []
|
|
302
|
+
text = klass.description || klass.name || "Query"
|
|
303
|
+
parts << (text.end_with?(".") ? text : "#{text}.")
|
|
304
|
+
|
|
305
|
+
if visible_filters.any?
|
|
306
|
+
filter_descs = visible_filters.map { |f| _query_filter_desc(klass, f) }
|
|
307
|
+
parts << "Filters: #{filter_descs.join(", ")}."
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
if allowed_sorts.any?
|
|
311
|
+
default_sort = klass._sort_default
|
|
312
|
+
sort_descs = allowed_sorts.map do |s|
|
|
313
|
+
bare_default = default_sort&.delete_prefix("-")
|
|
314
|
+
if bare_default && bare_default.to_sym == s
|
|
315
|
+
"#{s} (default: #{default_sort})"
|
|
316
|
+
else
|
|
317
|
+
s.to_s
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
parts << "Sorts: #{sort_descs.join(", ")}."
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
parts << "Returns up to #{max_limit} results per page. Use offset to paginate."
|
|
324
|
+
|
|
325
|
+
parts.join("\n")
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def _query_filter_desc(klass, filter_name)
|
|
329
|
+
descs = klass.respond_to?(:prop_descriptions) ? klass.prop_descriptions : {}
|
|
330
|
+
prop_desc = descs[filter_name]
|
|
331
|
+
|
|
332
|
+
prop = klass.literal_properties.find { |p| p.name == filter_name }
|
|
333
|
+
return filter_name.to_s unless prop
|
|
334
|
+
|
|
335
|
+
inner_type = prop.type
|
|
336
|
+
inner_type = inner_type.type if inner_type.is_a?(Literal::Types::NilableType)
|
|
337
|
+
|
|
338
|
+
if inner_type.respond_to?(:primitives) && inner_type.primitives&.any?
|
|
339
|
+
values = inner_type.primitives.to_a.map(&:to_s)
|
|
340
|
+
"#{filter_name} (#{_join_with_or(values)})"
|
|
341
|
+
elsif prop_desc
|
|
342
|
+
"#{filter_name} (#{prop_desc})"
|
|
343
|
+
else
|
|
344
|
+
filter_name.to_s
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def _join_with_or(values)
|
|
349
|
+
case values.size
|
|
350
|
+
when 1 then values.first
|
|
351
|
+
when 2 then "#{values.first} or #{values.last}"
|
|
352
|
+
else "#{values[..-2].join(", ")}, or #{values.last}"
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def _query_params_schema(klass, visible_filters, allowed_sorts, max_limit)
|
|
357
|
+
properties = {}
|
|
358
|
+
required = []
|
|
359
|
+
|
|
360
|
+
excluded = _query_excluded_prop_names(klass, visible_filters)
|
|
361
|
+
descs = klass.respond_to?(:prop_descriptions) ? klass.prop_descriptions : {}
|
|
362
|
+
|
|
363
|
+
if klass.respond_to?(:literal_properties)
|
|
364
|
+
klass.literal_properties.each do |prop|
|
|
365
|
+
next if excluded.include?(prop.name)
|
|
366
|
+
|
|
367
|
+
prop_desc = descs[prop.name]
|
|
368
|
+
schema = TypeSerializer.to_json_schema(prop.type, desc: prop_desc)
|
|
369
|
+
properties[prop.name.to_s] = schema
|
|
370
|
+
required << prop.name.to_s if prop.required?
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
if allowed_sorts.any?
|
|
375
|
+
sort_values = []
|
|
376
|
+
allowed_sorts.each do |s|
|
|
377
|
+
sort_def = klass._sort_registry[s]
|
|
378
|
+
sort_values << s.to_s
|
|
379
|
+
sort_values << "-#{s}" unless sort_def.custom
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
default_sort = klass._sort_default
|
|
383
|
+
sort_desc = "Sort order. Prefix with - for descending."
|
|
384
|
+
sort_desc += " Default: #{default_sort}" if default_sort
|
|
385
|
+
|
|
386
|
+
properties["sort"] = {
|
|
387
|
+
type: "string",
|
|
388
|
+
enum: sort_values,
|
|
389
|
+
description: sort_desc
|
|
390
|
+
}
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
properties["limit"] = {
|
|
394
|
+
type: "integer",
|
|
395
|
+
description: "Maximum number of results (default: #{max_limit}, max: #{max_limit})"
|
|
396
|
+
}
|
|
397
|
+
properties["offset"] = {
|
|
398
|
+
type: "integer",
|
|
399
|
+
description: "Number of results to skip (default: 0)"
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
|
|
403
|
+
result[:type] = "object"
|
|
404
|
+
result[:properties] = properties
|
|
405
|
+
result[:required] = required unless required.empty?
|
|
406
|
+
result[:additionalProperties] = false
|
|
407
|
+
result
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def _compute_visible_filters(klass, only_filters, except_filters)
|
|
411
|
+
context_keys = klass.context_mappings.keys.to_set
|
|
412
|
+
|
|
413
|
+
filters = klass.filters.dup
|
|
414
|
+
filters.reject! { |f| context_keys.include?(f) }
|
|
415
|
+
filters.reject! do |f|
|
|
416
|
+
prop = klass.literal_properties.find { |p| p.name == f }
|
|
417
|
+
prop && _ref_type?(prop.type)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
if only_filters
|
|
421
|
+
only_set = only_filters.map(&:to_sym).to_set
|
|
422
|
+
filters.select! { |f| only_set.include?(f) }
|
|
423
|
+
elsif except_filters
|
|
424
|
+
except_set = except_filters.map(&:to_sym).to_set
|
|
425
|
+
filters.reject! { |f| except_set.include?(f) }
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
filters
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def _query_excluded_prop_names(klass, visible_filters)
|
|
432
|
+
return Set.new unless klass.respond_to?(:literal_properties)
|
|
433
|
+
|
|
434
|
+
context_keys = klass.context_mappings.keys.to_set
|
|
435
|
+
visible_set = visible_filters.to_set
|
|
436
|
+
all_filters = klass.filters.to_set
|
|
437
|
+
|
|
438
|
+
excluded = Set.new
|
|
439
|
+
excluded.merge(context_keys)
|
|
440
|
+
|
|
441
|
+
klass.literal_properties.each do |prop|
|
|
442
|
+
excluded << prop.name if _ref_type?(prop.type)
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
invisible_filters = all_filters - visible_set
|
|
446
|
+
excluded.merge(invisible_filters)
|
|
447
|
+
|
|
448
|
+
excluded
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def _ref_type?(type)
|
|
452
|
+
return true if type.is_a?(Dex::RefType)
|
|
453
|
+
return _ref_type?(type.type) if type.respond_to?(:type)
|
|
454
|
+
|
|
455
|
+
false
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# --- Explain tool ---
|
|
459
|
+
|
|
460
|
+
def _build_explain_tool
|
|
81
461
|
Class.new(RubyLLM::Tool) do
|
|
82
462
|
define_method(:name) { "dex_explain" }
|
|
83
463
|
define_method(:description) { "Check if an operation can be executed with given params, without running it." }
|
|
@@ -110,6 +490,9 @@ module Dex
|
|
|
110
490
|
end.new
|
|
111
491
|
end
|
|
112
492
|
|
|
113
|
-
private_class_method :_require_ruby_llm!, :_build_tool, :_tool_description, :_build_explain_tool
|
|
493
|
+
private_class_method :_require_ruby_llm!, :_build_tool, :_tool_description, :_build_explain_tool,
|
|
494
|
+
:_reject_unknown_options!, :_validate_query_tool_options!, :_validate_satisfiable_props!,
|
|
495
|
+
:_build_query_tool, :_query_tool_description, :_query_filter_desc, :_join_with_or,
|
|
496
|
+
:_query_params_schema, :_compute_visible_filters, :_query_excluded_prop_names, :_ref_type?
|
|
114
497
|
end
|
|
115
498
|
end
|