xapixctl 1.1.0 → 1.2.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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