diff --git a/README.md b/README.md index 28ca740..fdc2998 100644 --- a/README.md +++ b/README.md @@ -29,13 +29,19 @@ $ gem install elasticsearch-explain-response ## Usage -### Summarize the explanation in one line +### Initialize the custom explainer ```ruby require 'elasticsearch' client = Elasticsearch::Client.new result = client.explain index: "megacorp", type: "employee", id: "1", q: "last_name:Smith" -puts Elasticsearch::API::Response::ExplainResponse.new(result["explanation"]).render_in_line +custom_explainer = Elasticsearch::API::Response::ExplainResponse.new(result["explanation"]) +``` + +### Summarize the explanation in one line + +```ruby +custom_explainer.render_in_line #=> 1.0 = (1.0(termFreq=1.0)) x 1.0(idf(2/3)) x 1.0(fieldNorm) ``` @@ -43,16 +49,54 @@ puts Elasticsearch::API::Response::ExplainResponse.new(result["explanation"]).re ### Summarize the explanation in lines ```ruby -require 'elasticsearch' -client = Elasticsearch::Client.new -result = client.explain index: "megacorp", type: "employee", id: "1", q: "last_name:Smith" -puts Elasticsearch::API::Response::ExplainResponse.new(result["explanation"]).render +custom_explainer.render #=> 1.0 = 1.0(fieldWeight) 1.0 = 1.0(tf(1.0)) x 1.0(idf(2/3)) x 1.0(fieldNorm) 1.0 = 1.0(termFreq=1.0) ``` +### Customize your rendering + +#### Translate your custom scripts + +```ruby +custom_painless_script = <<~PAINLESS.delete("\n") + ( + doc['response_rate'].value > 0.5 && + doc['unavailable_until'].empty + ) ? 1 : 0) +PAINLESS + +custom_translator => (lambda do |value| + if value == 1 + '1/1 Is available and good chance of reply' + else + '0/1 Not available or low chance of reply' + end +end) + +script_translation_map = { + custom_painless_script => custom_translator +} + +custom_explainer = Elasticsearch::API::Response::ExplainResponse.new( + result["explanation"], + script_translation_map: script_translation_map +) +custom_explainer.render +#=> +0.1 = 0.1(script(Custom script:0/1 Not available or low chance of reply)) + +``` + +#### Change basic formatting + +```ruby +custom_explainer.render( + precision: 6 # 6 decimals +) + ## Contributing 1. Fork it ( https://github.com/tomoya55/elasticsearch-explain-response/fork ) diff --git a/lib/elasticsearch/api/response/explain_node.rb b/lib/elasticsearch/api/response/explain_node.rb index c2f96a3..40923b4 100644 --- a/lib/elasticsearch/api/response/explain_node.rb +++ b/lib/elasticsearch/api/response/explain_node.rb @@ -1,11 +1,12 @@ require "elasticsearch/api/response/renderable" +require 'forwardable' module Elasticsearch module API module Response class ExplainNode include Renderable - extend Forwardable + extend ::Forwardable attr_reader :score, :description, :details, :level attr_accessor :children diff --git a/lib/elasticsearch/api/response/explain_parser.rb b/lib/elasticsearch/api/response/explain_parser.rb index 7bf230e..2aa836c 100644 --- a/lib/elasticsearch/api/response/explain_parser.rb +++ b/lib/elasticsearch/api/response/explain_parser.rb @@ -6,6 +6,17 @@ module Response class ExplainParser include Helpers::StringHelper + # @param [Hash>] script_translation_map: {} + # @example { + # "doc['has_custom_boost'].value": + # ->(value) { value == 1 ? 'Has a custom boost' : 'Does not have a custom boost' + # "doc['response_rate'].value >= 0.5 ? 1 : 0": + # ->(value) { value == 1 ? 'Has a good response rate' : 'Has a bad response rate <50%' + # } + def initialize(script_translation_map: {}) + @script_translation_map = script_translation_map + end + def parse(explain_tree) root = create_node(explain_tree, level: 0) parse_details(root) @@ -14,122 +25,138 @@ def parse(explain_tree) private - def create_node(detail, level:) - ExplainNode.new( - score: detail["value"] || 0.0, - description: parse_description(detail["description"]), - details: detail["details"] || [], - level: level - ) - end + def create_node(detail, level:) + ExplainNode.new( + score: detail["value"] || 0.0, + description: parse_description(detail["description"], node_value: detail["value"] || 0.0), + details: detail["details"] || [], + level: level + ) + end - def parse_details(node) - node.details.each do |detail| - child = create_node(detail, level: node.level.succ) - node.children << child - parse_details(child) - end + def parse_details(node) + node.details.each do |detail| + child = create_node(detail, level: node.level.succ) + node.children << child + parse_details(child) end + end - def parse_description(description) - case description - when /\Aweight\((\w+)\:(\w+)\s+in\s+\d+\)\s+\[\w+\]\, result of\:\z/ - type = "weight" - operation = "weight" - operator = "x" - field = $1 - value = $2 - when /\Aidf\(docFreq\=(\d+)\, maxDocs\=(\d+)\)\z/ - type = "idf" - operation = "idf(#{$1}/#{$2})" - when /\Atf\(freq\=([\d.]+)\)\, with freq of\:\z/ - type = "tf" - operation = "tf(#{$1})" - when /\Ascore\(doc\=\d+\,freq=[\d\.]+\)\, product of\:\z/ - type = "score" - operation = "score" - operator = "x" - when /\Amatch filter\: (?:cache\()?(?:(?[\w]+)\()*(?.+)\)*\z/ - type = "match" - operation = "match" - operation += ".#{$~[:op]}" if $~[:op] && !%w[QueryWrapperFilter].include?($~[:op]) - content = $~[:c] - content = content[0..-2] if content.end_with?(')') - hash = tokenize_contents(content) - field = hash.keys.join(", ") - value = hash.values.join(", ") - when /\AFunction for field ([\w\_]+)\:\z/ - type = "func" - operation = "func" - field = $1 - when /\AqueryWeight\, product of\:\z/ - type = "queryWeight" - operation = "queryWeight" - operator = "x" - when /\AfieldWeight in \d+\, product of\:\z/ - type = "fieldWeight" - operation = "fieldWeight" - operator = "x" - when /\AqueryNorm/ - type = "queryNorm" - operation = "queryNorm" - when /\Afunction score\, product of\:\z/, - /\Afunction score\, score mode \[multiply\]\z/ - type = "func score" - operator = "x" - when /\Afunction score\, score mode \[sum\]\z/ - type = "func score" - operator = "+" - when /\Ascript score function\, computed with script:\"(?.+)\"\s*(?:and parameters:\s*(?

.+))?/m + def parse_description(description, node_value:) + case description + when /\Aweight\((\w+)\:(\w+)\s+in\s+\d+\)\s+\[\w+\]\, result of\:\z/ + type = "weight" + operation = "weight" + operator = "x" + field = $1 + value = $2 + when /\Aidf\(docFreq\=(\d+)\, maxDocs\=(\d+)\)\z/ + type = "idf" + operation = "idf(#{$1}/#{$2})" + when /\Atf\(freq\=([\d.]+)\)\, with freq of\:\z/ + type = "tf" + operation = "tf(#{$1})" + when /\Ascore\(doc\=\d+\,freq=[\d\.]+\)\, product of\:\z/ + type = "score" + operation = "score" + operator = "x" + when /\Amatch filter\: (?:cache\()?(?:(?[\w]+)\()*(?.+)\)*\z/ + type = "match" + operation = "match" + operation += ".#{$~[:op]}" if $~[:op] && !%w[QueryWrapperFilter].include?($~[:op]) + content = $~[:c] + content = content[0..-2] if content.end_with?(')') + hash = tokenize_contents(content) + field = hash.keys.join(", ") + value = hash.values.join(", ") + when /\AFunction for field ([\w\_]+)\:\z/ + type = "func" + operation = "func" + field = $1 + when /\AqueryWeight\, product of\:\z/ + type = "queryWeight" + operation = "queryWeight" + operator = "x" + when /\AfieldWeight in \d+\, product of\:\z/ + type = "fieldWeight" + operation = "fieldWeight" + operator = "x" + when /\AqueryNorm/ + type = "queryNorm" + operation = "queryNorm" + when /\Afunction score\, product of\:\z/, + /\Afunction score\, score mode \[multiply\]\z/ + type = "func score" + operator = "x" + when /\Afunction score\, score mode \[sum\]\z/ + type = "func score" + operator = "+" + when /\Ascript score function\, computed with script:\"(?.+)\"\s*(?:and parameters:\s*(?

.+))?/m + operation = "script" + script, param = $~[:s], $~[:p] + param.gsub!("\n", '') if param + script = script.gsub("\n", '') + if (script_translator = translator_of_custom_script_function(script)) + type = "translated_script" + field = 'Custom script' + value = script_translator.call(node_value) + else type = "script" - operation = "script" - script, param = $~[:s], $~[:p] - script = script.gsub("\n", '') script = "\"#{script}\"" - param.gsub!("\n", '') if param field = script.scan(/doc\[\'([\w\.]+)\'\]/).flatten.uniq.compact.join(" ") value = [script, param].join(" ") - when /\AConstantScore\(.+\), product of\:\z/ - type = "constant" - operation = "constant" - when /\Aconstant score/ - type = "constant" - operation = "constant" - when "static boost factor", "boostFactor" - type = "boost" - operation = "boost" - when /product\sof\:?/, "[multiply]" - type = "product" - operation = "product" - operator = "x" - when "Math.min of" - type = "min" - operator = "min" - when "Math.max of" - type = "max" - operator = "max" - when /sum of\:?/ - type = "sum" - operator = "+" - when "maxBoost" - type = "maxBoost" - when /_score\:\s*/ - type = "score" - operation = "score" - else - type = description - operation = description end - - Description.new( - raw: description, - type: type, - operator: operator, - operation: operation, - field: field, - value: value, - ) + when /\AConstantScore\(.+\), product of\:\z/ + type = "constant" + operation = "constant" + when /\Aconstant score/ + type = "constant" + operation = "constant" + when "static boost factor", "boostFactor" + type = "boost" + operation = "boost" + when /product\sof\:?/, "[multiply]" + type = "product" + operation = "product" + operator = "x" + when "Math.min of" + type = "min" + operator = "min" + when "Math.max of" + type = "max" + operator = "max" + when /sum of\:?/ + type = "sum" + operator = "+" + when "maxBoost" + type = "maxBoost" + when /_score\:\s*/ + type = "score" + operation = "score" + else + type = description + operation = description end + + Description.new( + raw: description, + type: type, + operator: operator, + operation: operation, + field: field, + value: value, + ) + end + + # @param [String] ES script + # + # @return [Lambda] + # @yieldparam [Float] Associated ES score + # + def translator_of_custom_script_function(script) + code = script[/.*Code\='([^\,]*)\'/,1] + @script_translation_map[code] + end end end end diff --git a/lib/elasticsearch/api/response/explain_response.rb b/lib/elasticsearch/api/response/explain_response.rb index b30469f..1dde940 100644 --- a/lib/elasticsearch/api/response/explain_response.rb +++ b/lib/elasticsearch/api/response/explain_response.rb @@ -47,7 +47,8 @@ def result_as_hash(result, options = {}) def initialize(explain, options = {}) @explain = explain || {} @indent = 0 - @trim = options.has_key?(:trim) ? options.delete(:trim) : true + @trim = options.key?(:trim) ? options.delete(:trim) : true + @script_translation_map = options.delete(:script_translation_map) || {} @rendering_options = options parse_details @@ -69,7 +70,7 @@ def render_as_hash(&block) def parse_details @root ||= begin - tree = ExplainParser.new.parse(explain) + tree = ExplainParser.new(script_translation_map: @script_translation_map).parse(explain) tree = ExplainTrimmer.new.trim(tree) if trim tree end diff --git a/lib/elasticsearch/api/response/renderers/base_renderer.rb b/lib/elasticsearch/api/response/renderers/base_renderer.rb index 1161d4a..389f9f7 100644 --- a/lib/elasticsearch/api/response/renderers/base_renderer.rb +++ b/lib/elasticsearch/api/response/renderers/base_renderer.rb @@ -7,11 +7,16 @@ module Renderers class BaseRenderer include Helpers::ColorHelper + # @param [Hash] options + # @param options [Boolean] plain_score + # @param options [Boolean] show_values + # @param options [Integer] precision def initialize(options = {}) disable_colorization if options[:colorize] == false @max = options[:max] || 3 @plain_score = options[:plain_score] == true @show_values = options[:show_values] == true + @precision = options.delete(:precision) || 2 end private @@ -20,7 +25,7 @@ def render_score(score) value = if !@plain_score && score > 1_000 sprintf("%1.2g", score.round(2)) else - score.round(2).to_s + score.round(@precision).to_s end ansi(value, :magenta, :bright) end @@ -36,7 +41,7 @@ def render_description(description) text = '' text = description.operation if description.operation if description.field && description.value - if @show_values + if @show_values || description.type == 'translated_script' text += "(#{field(description.field)}:#{value(description.value)})" else text += "(#{field(description.field)})" diff --git a/spec/elasticsearch/api/response/explain_response_spec.rb b/spec/elasticsearch/api/response/explain_response_spec.rb index a720efa..1ee18b8 100644 --- a/spec/elasticsearch/api/response/explain_response_spec.rb +++ b/spec/elasticsearch/api/response/explain_response_spec.rb @@ -174,4 +174,55 @@ expect(subject).to include("\e[0m") end end + + describe 'script translation map' do + let(:fake_response) do + fixture_load(:response_with_named_scripts) + end + let(:custom_script) do + <<~PAINLESS.delete("\n") + (doc['response_rate'].value > 0.5 && + doc['unavailable_until'].empty + ) ? 1 : 0) + PAINLESS + end + let(:script_translation_map) do + { + custom_script => (lambda do |value| + if value == 1 + '1/1 Is available and good chance of reply' + else + '0/1 Not available or low chance of reply' + end + end) + } + end + + let(:response) do + described_class.new(fake_response["explanation"], + colorize: false, + script_translation_map: script_translation_map + ) + end + + subject do + response.render + end + + context 'when the ES value indicates a low response/unavailable' do + let(:explanation) { fake_response['explanation'] } + + it 'translate the script using the unavailable text' do + expect(subject).to include('0.0(script(Custom script:0/1 Not available or low chance of reply))') + end + end + + context 'when the ES value indicates a good response rate + availability' do + let(:explanation) { fake_response['explanation'] } + + it 'translate the script using the available text' do + expect(subject).to include('1.0(script(Custom script:1/1 Is available and good chance of reply))') + end + end + end end diff --git a/spec/fixtures/response_with_named_scripts.yml b/spec/fixtures/response_with_named_scripts.yml new file mode 100644 index 0000000..b17dce4 --- /dev/null +++ b/spec/fixtures/response_with_named_scripts.yml @@ -0,0 +1,39 @@ +--- +_index: "megacorp" +_type: "employee" +_id: "1" +matched: true +explanation: + value: 0.1 + description: "sum of:" + details: + - value: 0.1 + description: "function score, product of:" + details: + - value: 0.1 + description: "match filter: *:*" + - value: 1.0 + description: "script score function, computed with script:\ + \"Script{\ + type=inline, \ + lang='painless', \ + idOrCode=\'(\ + doc['response_rate'].value > 0.5 && \ + doc['unavailable_until'].empty) ? 1 : 0)', \ + options={}, params={}\ + }\" and parameters: \\n{}" + - value: 0.0 + description: "function score, product of:" + details: + - value: 0.1 + description: "match filter: *:*" + - value: 0.0 + description: "script score function, computed with script:\ + \"Script{\ + type=inline, \ + lang='painless', \ + idOrCode=\'(\ + doc['response_rate'].value > 0.5 && \ + doc['unavailable_until'].empty) ? 1 : 0)', \ + options={}, params={}\ + }\" and parameters: \\n{}"