diff --git a/app/graphql/timdex_schema.rb b/app/graphql/timdex_schema.rb index 697f5ec..1d2f19f 100644 --- a/app/graphql/timdex_schema.rb +++ b/app/graphql/timdex_schema.rb @@ -3,4 +3,26 @@ 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 4aff58b..62b32dd 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -105,18 +105,38 @@ def record_id(id:, index:) argument :subjects_filter, [String], required: false, default_value: nil, 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' 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, **filters) + 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 = 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 + } + results = Opensearch.new.search(from, query, Timdex::OSClient, highlight: highlight_requested?, index: index, fulltext: fulltext, query_mode: query_mode, requested_aggregations: requested_aggregations, - use_global_scoring: use_global_scoring) + use_global_scoring: use_global_scoring, + semantic_options: semantic_options) response = {} response[:hits] = results['hits']['total']['value'] diff --git a/app/models/hybrid_query_builder.rb b/app/models/hybrid_query_builder.rb index ddc4a88..4f791a8 100644 --- a/app/models/hybrid_query_builder.rb +++ b/app/models/hybrid_query_builder.rb @@ -1,5 +1,5 @@ class HybridQueryBuilder - def build(params, fulltext: false) + def build(params, fulltext: false, semantic_options: {}) query_text = params[:q].to_s.strip lexical_query = LexicalQueryBuilder.new.build(params, fulltext: fulltext) @@ -8,7 +8,7 @@ def build(params, fulltext: false) return lexical_query if query_text.blank? begin - semantic_query = SemanticQueryBuilder.new.build(params, fulltext: fulltext) + semantic_query = SemanticQueryBuilder.new.build(params, fulltext: fulltext, semantic_options: semantic_options) # Both succeeded - combine them with should clause while preserving filters combine_queries(semantic_query, lexical_query) diff --git a/app/models/opensearch.rb b/app/models/opensearch.rb index da0e413..2ac492a 100644 --- a/app/models/opensearch.rb +++ b/app/models/opensearch.rb @@ -4,12 +4,13 @@ class Opensearch MAX_SIZE = 200 def search(from, params, client, highlight: false, index: nil, fulltext: false, query_mode: 'keyword', - requested_aggregations: [], use_global_scoring: false) + requested_aggregations: [], use_global_scoring: false, semantic_options: {}) @params = params @highlight = highlight @fulltext = fulltext?(fulltext) @query_mode = query_mode @requested_aggregations = requested_aggregations + @semantic_options = semantic_options index = default_index unless index.present? search_params = { index:, body: build_query(from) } search_params[:search_type] = 'dfs_query_then_fetch' if use_global_scoring @@ -69,7 +70,12 @@ def query LexicalQueryBuilder.new end - builder.build(@params, fulltext: @fulltext) + # Only pass semantic_options to builders that support it (semantic and hybrid) + if @query_mode.in?(%w[semantic hybrid]) + builder.build(@params, fulltext: @fulltext, semantic_options: @semantic_options || {}) + else + builder.build(@params, fulltext: @fulltext) + end end def sort_builder diff --git a/app/models/semantic_query_builder.rb b/app/models/semantic_query_builder.rb index dafffe3..dd83e34 100644 --- a/app/models/semantic_query_builder.rb +++ b/app/models/semantic_query_builder.rb @@ -2,7 +2,7 @@ class SemanticQueryBuilder # Dedicated exception for Lambda invocation failures (not parsing/validation errors) class LambdaError < StandardError; end - def build(params, fulltext: false) + def build(params, fulltext: false, semantic_options: {}) query_text = params[:q].to_s.strip # If no query text provided, return a match_all query with filters applied @@ -14,7 +14,8 @@ def build(params, fulltext: false) return { match_all: {} } end - lambda_response = invoke_semantic_builder(query_text) + lambda_response = invoke_semantic_builder(query_text, semantic_options) + semantic_query = parse_lambda_response(lambda_response) # Validate the query structure has a bool clause before applying filters @@ -31,8 +32,23 @@ def build(params, fulltext: false) private - def invoke_semantic_builder(query_text) + def invoke_semantic_builder(query_text, semantic_options = {}) payload = { query: query_text } + + # Add optional semantic tuning parameters if provided + if semantic_options[:must_boost_threshold].present? + payload[:must_boost_threshold] = + semantic_options[:must_boost_threshold] + end + if semantic_options[:drop_boost_threshold].present? + payload[:drop_boost_threshold] = + semantic_options[:drop_boost_threshold] + end + if semantic_options[:short_query_max_tokens].present? + payload[:short_query_max_tokens] = + semantic_options[:short_query_max_tokens] + end + function_name = ENV.fetch('TIMDEX_SEMANTIC_BUILDER_FUNCTION_NAME') begin diff --git a/test/controllers/graphql_controller_test.rb b/test/controllers/graphql_controller_test.rb index e4c723a..3f8484b 100644 --- a/test/controllers/graphql_controller_test.rb +++ b/test/controllers/graphql_controller_test.rb @@ -1224,4 +1224,96 @@ class GraphqlControllerTest < ActionDispatch::IntegrationTest }' } assert_equal(200, response.status) end + + test 'graphql introspection hides internal semantic arguments via __schema' do + post '/graphql', params: { query: '{ + __schema { + queryType { + fields(includeDeprecated: true) { + name + args { + name + } + } + } + } + }' } + assert_equal(200, response.status) + json = JSON.parse(response.body) + + # Find the 'search' field + search_field = json['data']['__schema']['queryType']['fields'].find { |f| f['name'] == 'search' } + assert(search_field, 'search field should exist in schema') + + # 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 + + 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 + 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 + + test 'graphql internal semantic arguments still work in actual queries' 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 + ) { + 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']}" + assert_equal('Data analytics and big data', + json['data']['search']['records'].first['title']) + end + end + end end diff --git a/test/models/semantic_query_builder_test.rb b/test/models/semantic_query_builder_test.rb index 756511f..b8ae9b9 100644 --- a/test/models/semantic_query_builder_test.rb +++ b/test/models/semantic_query_builder_test.rb @@ -198,4 +198,186 @@ def setup_mock_lambda(response_data) assert_includes result[:bool].keys, :filter assert_equal [], result[:bool][:filter] end + + # Tests for semantic_options in Lambda payload + + test 'includes semantic options in lambda payload' do + query_text = 'test query' + mock_response = { + 'query' => { + 'bool' => { + 'should' => [ + { 'rank_feature' => { 'field' => 'embedding_full_record.test', 'boost' => 5.0 } } + ] + } + } + } + + captured_payload = nil + Aws::Lambda::Client.any_instance.expects(:invoke).with do |args| + captured_payload = JSON.parse(args[:payload]) + true + end.returns(Struct.new(:payload).new(StringIO.new(mock_response.to_json))) + + params = { q: query_text } + semantic_options = { must_boost_threshold: 0.5 } + @builder.build(params, semantic_options: semantic_options) + + # Verify the payload contains the semantic option + assert_equal 0.5, captured_payload['must_boost_threshold'] + end + + test 'includes multiple semantic options in lambda payload' do + query_text = 'test query' + mock_response = { + 'query' => { + 'bool' => { + 'should' => [ + { 'rank_feature' => { 'field' => 'embedding_full_record.test', 'boost' => 5.0 } } + ] + } + } + } + + captured_payload = nil + Aws::Lambda::Client.any_instance.expects(:invoke).with do |args| + captured_payload = JSON.parse(args[:payload]) + true + end.returns(Struct.new(:payload).new(StringIO.new(mock_response.to_json))) + + params = { q: query_text } + semantic_options = { + must_boost_threshold: 0.5, + drop_boost_threshold: 0.2, + short_query_max_tokens: 10 + } + @builder.build(params, semantic_options: semantic_options) + + # Verify all semantic options are in the payload + assert_equal 0.5, captured_payload['must_boost_threshold'] + assert_equal 0.2, captured_payload['drop_boost_threshold'] + assert_equal 10, captured_payload['short_query_max_tokens'] + end + + test 'omits nil semantic options from lambda payload' do + query_text = 'test query' + mock_response = { + 'query' => { + 'bool' => { + 'should' => [ + { 'rank_feature' => { 'field' => 'embedding_full_record.test', 'boost' => 5.0 } } + ] + } + } + } + + captured_payload = nil + Aws::Lambda::Client.any_instance.expects(:invoke).with do |args| + captured_payload = JSON.parse(args[:payload]) + true + end.returns(Struct.new(:payload).new(StringIO.new(mock_response.to_json))) + + params = { q: query_text } + semantic_options = { + must_boost_threshold: 0.5, + drop_boost_threshold: nil, + short_query_max_tokens: nil + } + @builder.build(params, semantic_options: semantic_options) + + # Verify only non-nil options are in the payload + assert_equal 0.5, captured_payload['must_boost_threshold'] + # Verify they are not keys in the payload hash + refute captured_payload.key?('drop_boost_threshold') + refute captured_payload.key?('short_query_max_tokens') + end + + test 'omits empty string semantic options from lambda payload' do + query_text = 'test query' + mock_response = { + 'query' => { + 'bool' => { + 'should' => [ + { 'rank_feature' => { 'field' => 'embedding_full_record.test', 'boost' => 5.0 } } + ] + } + } + } + + captured_payload = nil + Aws::Lambda::Client.any_instance.expects(:invoke).with do |args| + captured_payload = JSON.parse(args[:payload]) + true + end.returns(Struct.new(:payload).new(StringIO.new(mock_response.to_json))) + + params = { q: query_text } + semantic_options = { + must_boost_threshold: '', + drop_boost_threshold: 0.2 + } + @builder.build(params, semantic_options: semantic_options) + + # Verify empty string options are not included + refute captured_payload.key?('must_boost_threshold') + assert_equal 0.2, captured_payload['drop_boost_threshold'] + end + + test 'includes only query key when no semantic options provided' do + query_text = 'test query' + mock_response = { + 'query' => { + 'bool' => { + 'should' => [ + { 'rank_feature' => { 'field' => 'embedding_full_record.test', 'boost' => 5.0 } } + ] + } + } + } + + captured_payload = nil + Aws::Lambda::Client.any_instance.expects(:invoke).with do |args| + captured_payload = JSON.parse(args[:payload]) + true + end.returns(Struct.new(:payload).new(StringIO.new(mock_response.to_json))) + + params = { q: query_text } + @builder.build(params, semantic_options: {}) + + # Verify only query key is in the payload + assert_equal({ 'query' => query_text }, captured_payload) + end + + test 'semantic options payload contains both query and options' do + query_text = 'test query' + mock_response = { + 'query' => { + 'bool' => { + 'should' => [ + { 'rank_feature' => { 'field' => 'embedding_full_record.test', 'boost' => 5.0 } } + ] + } + } + } + + captured_payload = nil + Aws::Lambda::Client.any_instance.expects(:invoke).with do |args| + captured_payload = JSON.parse(args[:payload]) + true + end.returns(Struct.new(:payload).new(StringIO.new(mock_response.to_json))) + + params = { q: query_text } + semantic_options = { + must_boost_threshold: 0.75, + short_query_max_tokens: 5 + } + @builder.build(params, semantic_options: semantic_options) + + # Verify payload structure contains query and both options + expected_payload = { + 'query' => query_text, + 'must_boost_threshold' => 0.75, + 'short_query_max_tokens' => 5 + } + assert_equal expected_payload, captured_payload + end end