diff --git a/app/graphql/timdex_schema.rb b/app/graphql/timdex_schema.rb index 1d2f19f0..697f5ecd 100644 --- a/app/graphql/timdex_schema.rb +++ b/app/graphql/timdex_schema.rb @@ -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 diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 62b32dd3..c866ed52 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -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, - 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, @@ -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 + + 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 + + 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 + end + def collapse_buckets(es_aggs) return nil if es_aggs.nil? || es_aggs.empty? diff --git a/app/graphql/types/tuning_parameters_input_type.rb b/app/graphql/types/tuning_parameters_input_type.rb new file mode 100644 index 00000000..8f87fcfa --- /dev/null +++ b/app/graphql/types/tuning_parameters_input_type.rb @@ -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 diff --git a/test/controllers/graphql_controller_test.rb b/test/controllers/graphql_controller_test.rb index 3f8484b8..398c82a5 100644 --- a/test/controllers/graphql_controller_test.rb +++ b/test/controllers/graphql_controller_test.rb @@ -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 { @@ -1248,58 +1248,30 @@ 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 @@ -1307,13 +1279,66 @@ class GraphqlControllerTest < ActionDispatch::IntegrationTest } }' } 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