Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 0 additions & 22 deletions app/graphql/timdex_schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,4 @@ class TimdexSchema < GraphQL::Schema
trace_class(GraphQL::Tracing::LegacyTrace)

query(Types::QueryType)

use GraphQL::Schema::Visibility

# Hide arguments marked as "INTERNAL USE ONLY" from introspection queries
def self.visible?(member, context)
# 1. Detect if the member is one of your internal arguments
if member.respond_to?(:description) && member.description&.include?('INTERNAL USE ONLY')

# 2. Extract the root operation fields being requested
selected_fields = context.query&.selected_operation&.selections || []

# 3. Check if any root field is an introspection entrypoint (__schema or __type)
is_introspection = selected_fields.any? do |selection|
selection.respond_to?(:name) && %w[__schema __type].include?(selection.name)
end

# 4. Hide the arguments if GraphiQL/Introspection is sniffing the schema
return false if is_introspection
end

super
end
end
62 changes: 44 additions & 18 deletions app/graphql/types/query_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -106,31 +106,19 @@ def record_id(id:, index:)
description: 'Filter by subject terms. Use the `contentType` aggregation ' \
'for a list of possible values. Multiple values are ANDed.'

# Internal semantic query tuning parameters (not documented publicly). Must start with `INTERNAL USE ONLY` to be
# excluded from public documentation.
argument :semantic_must_boost_threshold, Float, required: false, default_value: nil,
description: 'INTERNAL USE ONLY: Semantic query must boost ' \
'threshold (0.0-1.0)'
argument :semantic_drop_boost_threshold, Float, required: false, default_value: nil,
description: 'INTERNAL USE ONLY: Semantic query drop boost ' \
'threshold (0.0-1.0)'
argument :semantic_short_query_max_tokens, Integer, required: false, default_value: nil,
description: 'INTERNAL USE ONLY: Semantic query short ' \
'query max tokens'
argument :tuning_parameters_input, TuningParametersInputType, required: false, default_value: nil,
description: 'Experimental tuning parameters ' \
'for semantic search. Not ' \
'recommended for use.'
end

def search(searchterm:, citation:, contributors:, funding_information:, geodistance:, geobox:, identifiers:,
locations:, subjects:, title:, index:, source:, from:, boolean_type:, fulltext:, per_page: 20,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 2 issues:

1. Function with many parameters (count = 20): search [qlty:function-parameters]


2. Avoid parameter lists longer than 5 parameters. [20/5] [rubocop:Metrics/ParameterLists]

query_mode: 'keyword', use_global_scoring: false, semantic_must_boost_threshold: nil,
semantic_drop_boost_threshold: nil, semantic_short_query_max_tokens: nil, **filters)
query_mode: 'keyword', use_global_scoring: false, tuning_parameters_input: nil, **filters)
query = construct_query(searchterm, citation, contributors, funding_information, geodistance, geobox, identifiers,
locations, subjects, title, source, boolean_type, filters, per_page, query_mode)

semantic_options = {
must_boost_threshold: semantic_must_boost_threshold,
drop_boost_threshold: semantic_drop_boost_threshold,
short_query_max_tokens: semantic_short_query_max_tokens
}
semantic_options = validate_and_build_semantic_options(tuning_parameters_input)

results = Opensearch.new.search(from, query, Timdex::OSClient, highlight: highlight_requested?, index: index,
fulltext: fulltext, query_mode: query_mode,
Expand Down Expand Up @@ -216,6 +204,44 @@ def source_deprecation_handler(query, new_source, old_source)
query
end

def validate_and_build_semantic_options(tuning_parameters)
return {} if tuning_parameters.blank?

semantic_options = {}

if tuning_parameters[:must_boost_threshold].present?
threshold = tuning_parameters[:must_boost_threshold]
unless threshold.between?(0.0, 1.0)
raise GraphQL::ExecutionError,
"tuningParametersInput.mustBoostThreshold must be between 0.0 and 1.0, got #{threshold}"
end
Comment thread
qltysh[bot] marked this conversation as resolved.
Comment thread
Copilot marked this conversation as resolved.

semantic_options[:must_boost_threshold] = threshold
end

if tuning_parameters[:drop_boost_threshold].present?
threshold = tuning_parameters[:drop_boost_threshold]
unless threshold.between?(0.0, 1.0)
raise GraphQL::ExecutionError,
"tuningParametersInput.dropBoostThreshold must be between 0.0 and 1.0, got #{threshold}"
end
Comment thread
qltysh[bot] marked this conversation as resolved.

semantic_options[:drop_boost_threshold] = threshold
end

if tuning_parameters[:short_query_max_tokens].present?
tokens = tuning_parameters[:short_query_max_tokens]
unless tokens.positive?
raise GraphQL::ExecutionError,
"tuningParametersInput.shortQueryMaxTokens must be greater than 0, got #{tokens}"
end

semantic_options[:short_query_max_tokens] = tokens
end

semantic_options
Comment thread
qltysh[bot] marked this conversation as resolved.
Comment thread
qltysh[bot] marked this conversation as resolved.
Comment thread
qltysh[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 3 issues:

1. Function with high complexity (count = 10): validate_and_build_semantic_options [qlty:function-complexity]


2. Assignment Branch Condition size for validate_and_build_semantic_options is too high. [<7, 19, 7> 21.42/17] [rubocop:Metrics/AbcSize]


3. Cyclomatic complexity for validate_and_build_semantic_options is too high. [8/7] [rubocop:Metrics/CyclomaticComplexity]

end

def collapse_buckets(es_aggs)
return nil if es_aggs.nil? || es_aggs.empty?

Expand Down
12 changes: 12 additions & 0 deletions app/graphql/types/tuning_parameters_input_type.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
module Types
class TuningParametersInputType < Types::BaseInputObject
description 'Experimental tuning parameters for semantic search. Not recommended for use.'

argument :must_boost_threshold, Float, required: false, default_value: nil,
description: 'Not recommended for use. Range: 0.0 to 1.0'
argument :drop_boost_threshold, Float, required: false, default_value: nil,
description: 'Not recommended for use. Range: 0.0 to 1.0'
argument :short_query_max_tokens, Integer, required: false, default_value: nil,
description: 'Not recommended for use. Must be greater than 0'
end
end
109 changes: 67 additions & 42 deletions test/controllers/graphql_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1225,7 +1225,7 @@ class GraphqlControllerTest < ActionDispatch::IntegrationTest
assert_equal(200, response.status)
end

test 'graphql introspection hides internal semantic arguments via __schema' do
test 'graphql introspection exposes tuningParametersInput argument' do
post '/graphql', params: { query: '{
__schema {
queryType {
Expand All @@ -1248,72 +1248,97 @@ class GraphqlControllerTest < ActionDispatch::IntegrationTest
# Get all argument names for the search field
arg_names = search_field['args'].map { |arg| arg['name'] }

# Verify internal semantic arguments are NOT present
assert_not_includes arg_names, 'semanticMustBoostThreshold'
assert_not_includes arg_names, 'semanticDropBoostThreshold'
assert_not_includes arg_names, 'semanticShortQueryMaxTokens'

# Verify other expected arguments ARE present
assert_includes arg_names, 'searchterm'
assert_includes arg_names, 'sourceFilter'
end
# Verify tuningParametersInput is now visible
assert_includes arg_names, 'tuningParametersInput'

test 'graphql introspection hides internal semantic arguments via __type' do
post '/graphql', params: { query: '{
__type(name: "Query") {
fields(includeDeprecated: true) {
name
args {
name
}
}
}
}' }
assert_equal(200, response.status)
json = JSON.parse(response.body)

# Find the 'search' field
search_field = json['data']['__type']['fields'].find { |f| f['name'] == 'search' }
assert(search_field, 'search field should exist in Query type')

# Get all argument names for the search field
arg_names = search_field['args'].map { |arg| arg['name'] }

# Verify internal semantic arguments are NOT present
# Verify old hidden arguments no longer exist
assert_not_includes arg_names, 'semanticMustBoostThreshold'
assert_not_includes arg_names, 'semanticDropBoostThreshold'
assert_not_includes arg_names, 'semanticShortQueryMaxTokens'

# Verify other expected arguments ARE present
# Verify other expected arguments are present
assert_includes arg_names, 'searchterm'
assert_includes arg_names, 'sourceFilter'
end

test 'graphql internal semantic arguments still work in actual queries' do
test 'graphql tuningParametersInput works with valid values' do
VCR.use_cassette('opensearch init') do
VCR.use_cassette('graphql search data analytics') do
# Verify that the arguments work when sent in a real query
# (they are just hidden from introspection)
post '/graphql', params: { query: '{
search(
searchterm: "data analytics",
semanticMustBoostThreshold: 0.5,
semanticDropBoostThreshold: 0.2,
semanticShortQueryMaxTokens: 10
tuningParametersInput: {
mustBoostThreshold: 0.5,
dropBoostThreshold: 0.2,
shortQueryMaxTokens: 10
}
) {
records {
title
}
}
}' }
assert_equal(200, response.status)
json = JSON.parse(response.body)

# Verify the query succeeded and returned records
assert_nil json['errors'], "Query should not have errors: #{json['errors']}"
json = JSON.parse(response.body)
assert_nil json['errors']
assert_equal('Data analytics and big data',
json['data']['search']['records'].first['title'])
end
end
end

test 'graphql tuningParametersInput rejects out-of-range mustBoostThreshold' do
post '/graphql', params: { query: '{
search(
searchterm: "test",
tuningParametersInput: {
mustBoostThreshold: 1.5
}
) {
hits
}
}' }
assert_equal(200, response.status)

json = JSON.parse(response.body)
assert json['errors'].present?
assert_includes json['errors'][0]['message'], 'mustBoostThreshold must be between 0.0 and 1.0'
end

test 'graphql tuningParametersInput rejects out-of-range dropBoostThreshold' do
post '/graphql', params: { query: '{
search(
searchterm: "test",
tuningParametersInput: {
dropBoostThreshold: -0.1
}
) {
hits
}
}' }
assert_equal(200, response.status)

json = JSON.parse(response.body)
assert json['errors'].present?
assert_includes json['errors'][0]['message'], 'dropBoostThreshold must be between 0.0 and 1.0'
end

test 'graphql tuningParametersInput rejects zero or negative shortQueryMaxTokens' do
post '/graphql', params: { query: '{
search(
searchterm: "test",
tuningParametersInput: {
shortQueryMaxTokens: 0
}
) {
hits
}
}' }
assert_equal(200, response.status)

json = JSON.parse(response.body)
assert json['errors'].present?
assert_includes json['errors'][0]['message'], 'shortQueryMaxTokens must be greater than 0'
end
end