xapixctl 1.1.0 → 1.2.2

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.
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Xapixctl
4
+ module PhoenixClient
5
+ class ProjectConnection < OrganizationConnection
6
+ attr_reader :project
7
+
8
+ def initialize(connection, org, project)
9
+ super(connection, org)
10
+ @project = project
11
+ end
12
+
13
+ def project_resource(format: :hash, &block)
14
+ organization.resource('Project', @project, format: format, &block)
15
+ end
16
+
17
+ def organization
18
+ OrganizationConnection.new(@connection, @org)
19
+ end
20
+
21
+ def resource_types_for_export
22
+ @resource_types_for_export ||=
23
+ @connection.available_resource_types do |res|
24
+ res.on_success do |available_types|
25
+ prj_types = available_types.select { |desc| desc['context'] == 'Project' }
26
+ SUPPORTED_RESOURCE_TYPES & prj_types.map { |desc| desc['type'] }
27
+ end
28
+ end
29
+ end
30
+
31
+ # Notes on parameters:
32
+ # - Query parameters should be part of the URL
33
+ # - Path parameters should be marked with `{name}` in the URL, and values should be given in path_params hash
34
+ # - Headers should be given in headers hash
35
+ # - Cookies should be given in cookies hash
36
+ # - The body has to be given as a string
37
+ # - The required authentication schemes should be listed, referring to previously created schemes
38
+ #
39
+ # This returns a hash like the following:
40
+ # "data_source" => { "id" => id, "resource_description" => resource_description }
41
+ #
42
+ # To successfully onboard a DB using the API, the following steps are needed:
43
+ # 1. setup the data source using add_rest_data_source.
44
+ # 2. retrieve a preview using preview_data_source using the id returned by previous step
45
+ # 3. confirm preview
46
+ # 4. call accept_data_source_preview to complete onboarding
47
+ #
48
+ def add_rest_data_source(http_method:, url:, path_params: {}, headers: {}, cookies: {}, body: nil, auth_schemes: [], &block)
49
+ data_source_details = {
50
+ data_source: {
51
+ http_method: http_method, url: url,
52
+ parameters: { path: path_params.to_query, header: headers.to_query, cookies: cookies.to_query, body: body },
53
+ auth_schemes: auth_schemes
54
+ }
55
+ }
56
+ result_handler(block).
57
+ run { @client[rest_data_source_path].post(data_source_details.to_json, content_type: :json) }
58
+ end
59
+
60
+ # Notes on parameters:
61
+ # - To call a data source which requires authentication, provide a hash with each required auth scheme as key and
62
+ # as the value a reference to a previously created credential.
63
+ # Example: { scheme_ref1 => credential_ref1, scheme_ref2 => credential_ref2 }
64
+ #
65
+ # This returns a hashified preview like the following:
66
+ # { "preview" => {
67
+ # "sample" => { "status" => integer, "body" => { ... }, "headers" => { ... }, "cookies" => { ... } },
68
+ # "fetched_at" => Timestamp },
69
+ # "data_source" => { "id" => id, "resource_description" => resource_description } }
70
+ #
71
+ def data_source_preview(data_source_id, authentications: {}, &block)
72
+ preview_data = {
73
+ authentications: authentications.map { |scheme, cred| { auth_scheme_id: scheme, auth_credential_id: cred } }
74
+ }
75
+ result_handler(block).
76
+ run { @client[data_source_preview_path(data_source_id)].post(preview_data.to_json, content_type: :json) }
77
+ end
78
+
79
+ def add_schema_import(spec_filename, &block)
80
+ spec_data = { schema_import: { file: File.new(spec_filename, 'r') } }
81
+ result_handler(block).
82
+ run { @client[schema_imports_path].post(spec_data) }
83
+ end
84
+
85
+ def update_schema_import(schema_import, spec_filename, &block)
86
+ spec_data = { schema_import: { file: File.new(spec_filename, 'r') } }
87
+ result_handler(block).
88
+ run { @client[schema_import_path(schema_import)].patch(spec_data) }
89
+ end
90
+
91
+ def pipeline_preview(pipeline_id, format: :hash, &block)
92
+ result_handler(block).
93
+ prepare_data(->(data) { data['pipeline_preview'] }).
94
+ formatter(PREVIEW_FORMATTERS[format]).
95
+ run { @client[pipeline_preview_path(pipeline_id)].get }
96
+ end
97
+
98
+ def endpoint_preview(endpoint_id, format: :hash, &block)
99
+ result_handler(block).
100
+ prepare_data(->(data) { data['endpoint_preview'] }).
101
+ formatter(PREVIEW_FORMATTERS[format]).
102
+ run { @client[endpoint_preview_path(endpoint_id)].get }
103
+ end
104
+
105
+ def stream_processor_preview(stream_processor_id, format: :hash, &block)
106
+ result_handler(block).
107
+ prepare_data(->(data) { data['stream_processor_preview'] }).
108
+ formatter(PREVIEW_FORMATTERS[format]).
109
+ run { @client[stream_processor_preview_path(stream_processor_id)].get }
110
+ end
111
+
112
+ def publish(&block)
113
+ result_handler(block).
114
+ run { @client[project_publications_path].post('') }
115
+ end
116
+
117
+ def logs(correlation_id, &block)
118
+ result_handler(block).
119
+ run { @client[project_logss_path(correlation_id)].get }
120
+ end
121
+
122
+ # This returns a hashified preview like the following:
123
+
124
+ def accept_data_source_preview(data_source_id, &block)
125
+ result_handler(block).
126
+ run { @client[data_source_preview_path(data_source_id)].patch('') }
127
+ end
128
+
129
+ def public_project_url
130
+ File.join(@connection.xapix_url, @org, @project)
131
+ end
132
+
133
+ private
134
+
135
+ def rest_data_source_path
136
+ "/projects/#{@org}/#{@project}/onboarding/data_sources/rest"
137
+ end
138
+
139
+ def data_source_preview_path(id)
140
+ "/projects/#{@org}/#{@project}/onboarding/data_sources/#{id}/preview"
141
+ end
142
+
143
+ def schema_imports_path
144
+ "/projects/#{@org}/#{@project}/onboarding/schema_imports"
145
+ end
146
+
147
+ def schema_import_path(schema_import)
148
+ "/projects/#{@org}/#{@project}/onboarding/schema_imports/#{schema_import}"
149
+ end
150
+
151
+ def resource_path(type, id)
152
+ "/projects/#{@org}/#{@project}/#{translate_type(type)}/#{id}"
153
+ end
154
+
155
+ def resources_path(type)
156
+ "/projects/#{@org}/#{@project}/#{translate_type(type)}"
157
+ end
158
+
159
+ def generic_resource_path
160
+ "projects/#{@org}/#{@project}/resource"
161
+ end
162
+
163
+ def pipeline_preview_path(pipeline)
164
+ "/projects/#{@org}/#{@project}/pipelines/#{pipeline}/preview"
165
+ end
166
+
167
+ def endpoint_preview_path(endpoint)
168
+ "/projects/#{@org}/#{@project}/endpoints/#{endpoint}/preview"
169
+ end
170
+
171
+ def stream_processor_preview_path(stream_processor)
172
+ "/projects/#{@org}/#{@project}/stream_processors/#{stream_processor}/preview"
173
+ end
174
+
175
+ def project_publications_path
176
+ "/projects/#{@org}/#{@project}/publications"
177
+ end
178
+
179
+ def project_logss_path(correlation_id)
180
+ "/projects/#{@org}/#{@project}/logs/#{correlation_id}"
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Xapixctl
4
+ module PhoenixClient
5
+ class ResultHandler
6
+ def initialize(default_success_handler:, default_error_handler:)
7
+ @success_handler = default_success_handler
8
+ @error_handler = default_error_handler
9
+ @result_handler = nil
10
+ yield self if block_given?
11
+ end
12
+
13
+ def on_success(&block); @success_handler = block; self; end
14
+
15
+ def on_error(&block); @error_handler = block; self; end
16
+
17
+ def prepare_data(proc); @result_handler = proc; self; end
18
+
19
+ def formatter(proc); @formatter = proc; self; end
20
+
21
+ def run
22
+ res = yield
23
+ res = res.present? ? JSON.parse(res) : res
24
+ res = @result_handler ? @result_handler.call(res) : res
25
+ res = @formatter ? @formatter.call(res) : res
26
+ @success_handler.call(res)
27
+ rescue RestClient::Exception => err
28
+ response = JSON.parse(err.response) rescue {}
29
+ @error_handler.call(err, response)
30
+ rescue SocketError, Errno::ECONNREFUSED => err
31
+ @error_handler.call(err, nil)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'xapixctl/base_cli'
4
+
5
+ module Xapixctl
6
+ class PreviewCli < BaseCli
7
+ option :format, aliases: "-f", default: 'text', enum: ['text', 'yaml', 'json'], desc: "Output format"
8
+ desc "pipeline ID", "Preview a pipeline"
9
+ long_desc <<-LONGDESC
10
+ `xapixctl preview pipeline` will return a preview of the given pipeline.
11
+
12
+ The preview function will not call any external data sources but calculate a preview based on the provided sample data.
13
+
14
+ To preview a pipeline attached to an endpoint, please use `xapixctl preview endpoint` to see the correct preview.
15
+
16
+ Examples:
17
+ \x5> $ xapixctl preview pipeline -o xapix -p some-project pipeline
18
+ \x5> $ xapixctl preview pipeline -p xapix/some-project pipeline
19
+ LONGDESC
20
+ def pipeline(pipeline)
21
+ puts prj_connection.pipeline_preview(pipeline, format: options[:format].to_sym)
22
+ end
23
+
24
+ option :format, aliases: "-f", default: 'text', enum: ['text', 'yaml', 'json'], desc: "Output format"
25
+ desc "endpoint ID", "Preview an endpoint"
26
+ long_desc <<-LONGDESC
27
+ `xapixctl preview endpoint` will return a preview of the given endpoint.
28
+
29
+ The preview function will not call any external data sources but calculate a preview based on the provided sample data.
30
+
31
+ Examples:
32
+ \x5> $ xapixctl preview endpoint -o xapix -p some-project endpoint
33
+ \x5> $ xapixctl preview endpoint -p xapix/some-project endpoint
34
+ LONGDESC
35
+ def endpoint(endpoint)
36
+ puts prj_connection.endpoint_preview(endpoint, format: options[:format].to_sym)
37
+ end
38
+
39
+ option :format, aliases: "-f", default: 'text', enum: ['text', 'yaml', 'json'], desc: "Output format"
40
+ desc "stream-processor ID", "Preview a stream processor"
41
+ long_desc <<-LONGDESC
42
+ `xapixctl preview stream-processor` will return a preview of the given stream processor.
43
+
44
+ The preview function will not call any external data sources but calculate a preview based on the provided sample data.
45
+
46
+ Examples:
47
+ \x5> $ xapixctl preview stream-processor -o xapix -p some-project processor
48
+ \x5> $ xapixctl preview stream-processor -p xapix/some-project processor
49
+ LONGDESC
50
+ def stream_processor(stream_processor)
51
+ puts prj_connection.stream_processor_preview(stream_processor, format: options[:format].to_sym)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'xapixctl/base_cli'
4
+ require 'pathname'
5
+
6
+ module Xapixctl
7
+ class SyncCli < BaseCli
8
+ class_option :credentials, desc: "Whether to include Credential resources in sync", type: :boolean, default: true
9
+ class_option :exclude_types, desc: "Resource types to exclude from sync", type: :array
10
+
11
+ desc "to-dir DIRECTORY", "Syncs resources in project to directory"
12
+ long_desc <<-LONGDESC
13
+ `xapixctl sync to-dir DIRECTORY` will export all resources of a given project and remove any additional resources from the directory.
14
+
15
+ With --no-credentials you can exclude all credentials from getting exported.
16
+
17
+ With --exclude-types you can specify any resource types besides Project you'd like to exclude.
18
+
19
+ When excluding types, the excluded types will be recorded in the sync directory in a file called .excluded_types, so that any future syncs will exclude those types.
20
+
21
+ Examples:
22
+ \x5> $ xapixctl sync to-dir ./project_dir -p xapix/some-project
23
+ \x5> $ xapixctl sync to-dir ./project_dir -p xapix/some-project --no-credentials
24
+ \x5> $ xapixctl sync to-dir ./project_dir -p xapix/some-project --exclude-types=ApiPublishing ApiPublishingRole Credential
25
+ LONGDESC
26
+ def to_dir(dir)
27
+ sync_path = SyncPath.new(dir, prj_connection.resource_types_for_export, excluded_types)
28
+
29
+ res_details = prj_connection.project_resource
30
+ sync_path.write_file(generate_readme(res_details), 'README.md')
31
+ sync_path.write_resource_yaml(res_details, 'project')
32
+
33
+ sync_path.types_to_sync.each do |type|
34
+ res_path = sync_path.resource_path(type)
35
+ prj_connection.resource_ids(type).each do |res_id|
36
+ res_details = prj_connection.resource(type, res_id)
37
+ res_path.write_resource_yaml(res_details, res_id)
38
+ end
39
+ res_path.remove_outdated_resources
40
+ end
41
+ sync_path.update_excluded_types_file
42
+ end
43
+
44
+ desc "from-dir DIRECTORY", "Syncs resources in project from directory"
45
+ long_desc <<-LONGDESC
46
+ `xapixctl sync from-dir project dir` will import all resources into the given project from the directory and remove any additional resources which are not present in the directory.
47
+
48
+ With --no-credentials you can exclude all credentials from getting exported.
49
+
50
+ With --exclude-types you can specify any resource types besides Project you'd like to exclude.
51
+
52
+ Examples:
53
+ \x5> $ xapixctl sync from-dir ./project_dir -p xapix/some-project
54
+ \x5> $ xapixctl sync from-dir ./project_dir -p xapix/some-project --no-credentials
55
+ \x5> $ xapixctl sync from-dir ./project_dir -p xapix/some-project --exclude-types=ApiPublishing ApiPublishingRole Credential
56
+ LONGDESC
57
+ def from_dir(dir)
58
+ sync_path = SyncPath.new(dir, prj_connection.resource_types_for_export, excluded_types)
59
+
60
+ sync_path.load_resource('project') do |desc|
61
+ puts "applying #{desc['kind']} #{desc.dig('metadata', 'id')} to #{prj_connection.project}"
62
+ desc['metadata']['id'] = prj_connection.project
63
+ prj_connection.organization.apply(desc)
64
+ end
65
+
66
+ outdated_resources = {}
67
+ sync_path.types_to_sync.each do |type|
68
+ res_path = sync_path.resource_path(type)
69
+ updated_resource_ids = []
70
+ res_path.load_resources do |desc|
71
+ puts "applying #{desc['kind']} #{desc.dig('metadata', 'id')}"
72
+ updated_resource_ids += prj_connection.apply(desc)
73
+ end
74
+ outdated_resources[type] = prj_connection.resource_ids(type) - updated_resource_ids
75
+ end
76
+
77
+ outdated_resources.each do |type, resource_ids|
78
+ resource_ids.each do |resource_id|
79
+ puts "removing #{type} #{resource_id}"
80
+ prj_connection.delete(type, resource_id)
81
+ end
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ class ResourcePath
88
+ def initialize(path)
89
+ @path = path
90
+ @resource_files = []
91
+ end
92
+
93
+ def write_file(content, filename)
94
+ @path.mkpath
95
+ unless @path.directory? && @path.writable?
96
+ warn "Cannot write to #{@path}, please check directory exists and is writable"
97
+ exit 1
98
+ end
99
+ file = @path.join(filename)
100
+ file.write(content)
101
+ puts "updated #{file}..."
102
+ file
103
+ end
104
+
105
+ def write_resource_yaml(res_details, res_name)
106
+ file = write_file(res_details.to_yaml, "#{res_name}.yaml")
107
+ @resource_files << file
108
+ file
109
+ end
110
+
111
+ def load_resources(&block)
112
+ Util.resources_from_file(@path, ignore_missing: true, &block)
113
+ end
114
+
115
+ def load_resource(res_name, &block)
116
+ Util.resources_from_file(@path.join("#{res_name}.yaml"), ignore_missing: false, &block)
117
+ end
118
+
119
+ def remove_outdated_resources
120
+ (@path.glob('*.yaml') - @resource_files).each do |outdated_file|
121
+ outdated_file.delete
122
+ puts "removed #{outdated_file}"
123
+ end
124
+ end
125
+ end
126
+
127
+ class SyncPath < ResourcePath
128
+ attr_reader :types_to_sync
129
+
130
+ def initialize(dir, all_types, excluded_types)
131
+ super(Pathname.new(dir))
132
+ @all_types = all_types
133
+ @excluded_types_file = @path.join('.excluded_types')
134
+ @excluded_types = excluded_types || []
135
+ @excluded_types += @excluded_types_file.read.split if @excluded_types_file.exist?
136
+ @excluded_types &= @all_types
137
+ @excluded_types.sort!
138
+ @types_to_sync = @all_types - @excluded_types
139
+ puts "Resource types excluded from sync: #{@excluded_types.join(', ')}" if @excluded_types.any?
140
+ end
141
+
142
+ def resource_path(type)
143
+ ResourcePath.new(@path.join(type.underscore))
144
+ end
145
+
146
+ def update_excluded_types_file
147
+ @excluded_types_file.write(@excluded_types.join(" ") + "\n") if @excluded_types.any?
148
+ end
149
+ end
150
+
151
+ def excluded_types
152
+ excluded = options[:exclude_types]
153
+ excluded += ['Credential'] unless options[:credentials]
154
+ excluded
155
+ end
156
+
157
+ def generate_readme(res_details)
158
+ <<~EOREADME
159
+ # #{res_details.dig('definition', 'name')}
160
+ #{res_details.dig('definition', 'description')}
161
+
162
+ Project exported from #{File.join(prj_connection.public_project_url)} by xapixctl v#{Xapixctl::VERSION}.
163
+ EOREADME
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'xapixctl/base_cli'
4
+
5
+ module Xapixctl
6
+ class TitanCli < BaseCli
7
+ DEFAULT_METHODS = {
8
+ "predict" => :predict,
9
+ "performance" => :performance,
10
+ }.freeze
11
+
12
+ option :schema_import, desc: "Resource id of an existing Schema import"
13
+ option :data, desc: "JSON encoded data for predict API", default: "[[1,2,3]]"
14
+ desc "service URL", "build a service for the deployed model"
15
+ long_desc <<-LONGDESC
16
+ `xapixctl titan service URL` will build a ML service around a deployed ML model.
17
+
18
+ We expect the following of the deployed ML model:
19
+
20
+ - There should be a "POST /predict" endpoint. Use --data="JSON" to specify an example dataset the model expects.
21
+
22
+ - If there is a "GET /performance" endpoint, it'll be made available. It's expected to return a JSON object with the properties 'accuracy', 'precision', 'recall', and 'min_acc_threshold'.
23
+
24
+ Examples:
25
+ \x5> $ xapixctl titan service https://services.demo.akoios.com/ai-model-name -p xapix/ml-project
26
+ LONGDESC
27
+ def service(akoios_url)
28
+ url = URI.parse(akoios_url)
29
+ schema = JSON.parse(RestClient.get(File.join(url.to_s, 'spec'), params: { host: url.hostname }, headers: { accept: :json }))
30
+ patch_schema(schema)
31
+ connector_refs = import_swagger(File.basename(url.path), schema)
32
+ say "\n== Onboarding Connectors", :bold
33
+ connectors = match_connectors_to_action(connector_refs)
34
+ say "\n== Building Service", :bold
35
+ service_doc = build_service(schema.dig('info', 'title'), connectors)
36
+ res = prj_connection.apply(service_doc)
37
+ say "\ncreated / updated service #{res.first}"
38
+ end
39
+
40
+ private
41
+
42
+ def patch_schema(schema)
43
+ predict_schema = schema.dig('paths', '/predict', 'post')
44
+ if predict_schema
45
+ predict_data = JSON.parse(options[:data]) rescue {}
46
+ predict_schema['operationId'] = 'predict'
47
+ predict_schema['parameters'].each do |param|
48
+ if param['name'] == 'json' && param['in'] == 'body'
49
+ param['schema']['properties'] = { "data" => extract_schema(predict_data) }
50
+ param['schema']['example'] = { "data" => predict_data }
51
+ end
52
+ end
53
+ end
54
+
55
+ performane_schema = schema.dig('paths', '/performance', 'get')
56
+ if performane_schema
57
+ performane_schema['operationId'] = 'performance'
58
+ end
59
+ end
60
+
61
+ def import_swagger(filename, schema)
62
+ Tempfile.create([filename, '.json']) do |f|
63
+ f.write(schema.to_json)
64
+ f.rewind
65
+
66
+ if options[:schema_import]
67
+ result = prj_connection.update_schema_import(options[:schema_import], f)
68
+ say "updated #{result.dig('resource', 'kind')} #{result.dig('resource', 'id')}"
69
+ else
70
+ result = prj_connection.add_schema_import(f)
71
+ say "created #{result.dig('resource', 'kind')} #{result.dig('resource', 'id')}"
72
+ end
73
+ result.dig('schema_import', 'updated_resources')
74
+ end
75
+ end
76
+
77
+ def match_connectors_to_action(connector_refs)
78
+ connector_refs.map do |connector_ref|
79
+ connector = prj_connection.resource(connector_ref['kind'], connector_ref['id'])
80
+ action = DEFAULT_METHODS[connector.dig('definition', 'name')]
81
+ say "\n#{connector['kind']} #{connector.dig('definition', 'name')} -> "
82
+ if action
83
+ say "#{action} action"
84
+ [action, update_connector_with_preview(connector)]
85
+ else
86
+ say "no action type detected, ignoring"
87
+ nil
88
+ end
89
+ end.compact
90
+ end
91
+
92
+ def update_connector_with_preview(connector)
93
+ say "fetching preview for #{connector['kind']} #{connector.dig('definition', 'name')}..."
94
+ preview_details = prj_connection.data_source_preview(connector.dig('metadata', 'id'))
95
+ preview = preview_details.dig('preview', 'sample')
96
+ say "got a #{preview['status']} response: #{preview['body']}"
97
+ if preview['status'] != 200
98
+ say "unexpected status, please check data or model"
99
+ elsif yes?("Does this look alright?", :bold)
100
+ res = prj_connection.accept_data_source_preview(connector.dig('metadata', 'id'))
101
+ return res.dig('data_source', 'resource_description')
102
+ end
103
+ connector
104
+ end
105
+
106
+ def extract_schema(data_sample)
107
+ case data_sample
108
+ when Array
109
+ { type: 'array', items: extract_schema(data_sample[0]) }
110
+ when Hash
111
+ { type: 'object', properties: data_sample.transform_values { |v| extract_schema(v) } }
112
+ when Numeric
113
+ { type: 'number' }
114
+ else
115
+ {}
116
+ end
117
+ end
118
+
119
+ def build_service(title, connectors)
120
+ {
121
+ version: 'v1',
122
+ kind: 'Service',
123
+ metadata: { id: title.parameterize },
124
+ definition: {
125
+ name: title.humanize,
126
+ actions: connectors.map { |action, connector| build_service_action(action, connector) }
127
+ }
128
+ }
129
+ end
130
+
131
+ def build_service_action(action_type, connector)
132
+ {
133
+ name: action_type,
134
+ parameter_schema: parameter_schema(action_type, connector),
135
+ result_schema: result_schema(action_type),
136
+ pipeline: { units: pipeline_units(action_type, connector) }
137
+ }
138
+ end
139
+
140
+ def parameter_schema(action_type, connector)
141
+ case action_type
142
+ when :predict
143
+ { type: 'object', properties: {
144
+ data: connector.dig('definition', 'parameter_schema', 'properties', 'body', 'properties', 'data'),
145
+ } }
146
+ when :performance
147
+ { type: 'object', properties: {} }
148
+ end
149
+ end
150
+
151
+ def result_schema(action_type)
152
+ case action_type
153
+ when :predict
154
+ { type: 'object', properties: {
155
+ prediction: { type: 'object', properties: { percent: { type: 'number' }, raw: { type: 'number' } } },
156
+ success: { type: 'boolean' },
157
+ error: { type: 'string' }
158
+ } }
159
+ when :performance
160
+ { type: 'object', properties: {
161
+ performance: {
162
+ type: 'object', properties: {
163
+ accuracy: { type: 'number' },
164
+ precision: { type: 'number' },
165
+ recall: { type: 'number' },
166
+ min_acc_threshold: { type: 'number' }
167
+ }
168
+ }
169
+ } }
170
+ end
171
+ end
172
+
173
+ def pipeline_units(action_type, connector)
174
+ case action_type
175
+ when :predict
176
+ [entry_unit, predict_unit(connector), predict_result_unit]
177
+ when :performance
178
+ [entry_unit, performance_unit(connector), performance_result_unit]
179
+ end
180
+ end
181
+
182
+ def entry_unit
183
+ { version: 'v2', kind: 'Unit/Entry',
184
+ metadata: { id: 'entry' },
185
+ definition: { name: 'Entry' } }
186
+ end
187
+
188
+ def predict_unit(connector)
189
+ leafs = extract_leafs(connector.dig('definition', 'parameter_schema', 'properties', 'body'))
190
+ { version: 'v2', kind: 'Unit/DataSource',
191
+ metadata: { id: 'predict' },
192
+ definition: {
193
+ name: 'Predict',
194
+ inputs: ['entry'],
195
+ data_source: connector.dig('metadata', 'id'),
196
+ formulas: leafs.map { |leaf|
197
+ { ref: leaf[:node]['$id'], formula: (['', 'entry'] + leaf[:path]).join('.') }
198
+ }
199
+ } }
200
+ end
201
+
202
+ def predict_result_unit
203
+ { version: 'v2', kind: 'Unit/Result',
204
+ metadata: { id: 'result' },
205
+ definition: {
206
+ name: 'Result',
207
+ inputs: ['predict'],
208
+ formulas: [{
209
+ ref: "#prediction.raw",
210
+ formula: "IF(.predict.status = 200, ROUND(100*coerce.to-float(.predict.body), 2))"
211
+ }, {
212
+ ref: "#prediction.percent",
213
+ formula: "IF(.predict.status = 200, coerce.to-float(.predict.body))"
214
+ }, {
215
+ ref: "#success",
216
+ formula: ".predict.status = 200"
217
+ }, {
218
+ ref: "#error",
219
+ formula: "IF(.predict.status <> 200, 'Model not trained!')"
220
+ }],
221
+ parameter_sample: {
222
+ "prediction" => { "percent" => 51, "raw" => 0.5112131 },
223
+ "success" => true,
224
+ "error" => nil
225
+ }
226
+ } }
227
+ end
228
+
229
+ def performance_unit(connector)
230
+ { version: 'v2', kind: 'Unit/DataSource',
231
+ metadata: { id: 'performance' },
232
+ definition: {
233
+ name: 'Performance',
234
+ inputs: ['entry'],
235
+ data_source: connector.dig('metadata', 'id')
236
+ } }
237
+ end
238
+
239
+ def performance_result_unit
240
+ { version: 'v2', kind: 'Unit/Result',
241
+ metadata: { id: 'result' },
242
+ definition: {
243
+ name: 'Result',
244
+ inputs: ['performance'],
245
+ formulas: [{
246
+ ref: "#performance.recall",
247
+ formula: "WITH(data, JSON.DECODE(REGEXREPLACE(performance.body, \"'\", \"\\\"\")), .data.recall)"
248
+ }, {
249
+ ref: "#performance.accuracy",
250
+ formula: "WITH(data, JSON.DECODE(REGEXREPLACE(performance.body, \"'\", \"\\\"\")), .data.accuracy)"
251
+ }, {
252
+ ref: "#performance.precision",
253
+ formula: "WITH(data, JSON.DECODE(REGEXREPLACE(performance.body, \"'\", \"\\\"\")), .data.precision)"
254
+ }, {
255
+ ref: "#performance.min_acc_threshold",
256
+ formula: "WITH(data, JSON.DECODE(REGEXREPLACE(performance.body, \"'\", \"\\\"\")), .data.min_acc_threshold)"
257
+ }],
258
+ parameter_sample: {
259
+ "performance" => {
260
+ "recall" => 0.8184713375796179,
261
+ "accuracy" => 0.9807847896440129,
262
+ "precision" => 0.8711864406779661,
263
+ "min_acc_threshold" => 0.84,
264
+ }
265
+ }
266
+ } }
267
+ end
268
+
269
+ def extract_leafs(schema, current_path = [])
270
+ return unless schema
271
+ case schema['type']
272
+ when 'object'
273
+ schema['properties'].flat_map { |key, sub_schema| extract_leafs(sub_schema, current_path + [key]) }.compact
274
+ when 'array'
275
+ extract_leafs(schema['items'], current_path + [:[]])
276
+ else
277
+ { path: current_path, node: schema }
278
+ end
279
+ end
280
+ end
281
+ end