noiseless 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +28 -0
  3. data/README.md +214 -0
  4. data/lib/application_search.rb +15 -0
  5. data/lib/noiseless/adapter.rb +313 -0
  6. data/lib/noiseless/adapters/elasticsearch.rb +70 -0
  7. data/lib/noiseless/adapters/execution_modules/elasticsearch_execution.rb +188 -0
  8. data/lib/noiseless/adapters/execution_modules/opensearch_execution.rb +377 -0
  9. data/lib/noiseless/adapters/execution_modules/pgvector_support.rb +219 -0
  10. data/lib/noiseless/adapters/execution_modules/postgresql_execution.rb +461 -0
  11. data/lib/noiseless/adapters/execution_modules/typesense_execution.rb +472 -0
  12. data/lib/noiseless/adapters/open_search.rb +208 -0
  13. data/lib/noiseless/adapters/postgresql.rb +171 -0
  14. data/lib/noiseless/adapters/typesense.rb +70 -0
  15. data/lib/noiseless/adapters.rb +14 -0
  16. data/lib/noiseless/ast/aggregation.rb +56 -0
  17. data/lib/noiseless/ast/bool.rb +16 -0
  18. data/lib/noiseless/ast/bulk.rb +18 -0
  19. data/lib/noiseless/ast/collapse.rb +16 -0
  20. data/lib/noiseless/ast/combined_fields.rb +33 -0
  21. data/lib/noiseless/ast/conversation.rb +29 -0
  22. data/lib/noiseless/ast/filter.rb +15 -0
  23. data/lib/noiseless/ast/hybrid.rb +35 -0
  24. data/lib/noiseless/ast/image_query.rb +29 -0
  25. data/lib/noiseless/ast/join.rb +31 -0
  26. data/lib/noiseless/ast/match.rb +15 -0
  27. data/lib/noiseless/ast/multi_match.rb +24 -0
  28. data/lib/noiseless/ast/paginate.rb +15 -0
  29. data/lib/noiseless/ast/prefix.rb +15 -0
  30. data/lib/noiseless/ast/range.rb +18 -0
  31. data/lib/noiseless/ast/root.rb +69 -0
  32. data/lib/noiseless/ast/search_after.rb +14 -0
  33. data/lib/noiseless/ast/sort.rb +15 -0
  34. data/lib/noiseless/ast/vector.rb +27 -0
  35. data/lib/noiseless/ast/wildcard.rb +15 -0
  36. data/lib/noiseless/ast.rb +30 -0
  37. data/lib/noiseless/bulk_importer.rb +195 -0
  38. data/lib/noiseless/callbacks.rb +138 -0
  39. data/lib/noiseless/connection_manager.rb +26 -0
  40. data/lib/noiseless/document_manager.rb +137 -0
  41. data/lib/noiseless/dsl.rb +107 -0
  42. data/lib/noiseless/generators/application_search_generator.rb +24 -0
  43. data/lib/noiseless/instrumentation.rb +174 -0
  44. data/lib/noiseless/introspection/console.rb +228 -0
  45. data/lib/noiseless/introspection/query_visualizer.rb +533 -0
  46. data/lib/noiseless/introspection.rb +221 -0
  47. data/lib/noiseless/mapping.rb +253 -0
  48. data/lib/noiseless/mapping_definition_processor.rb +231 -0
  49. data/lib/noiseless/model.rb +111 -0
  50. data/lib/noiseless/model_registry.rb +77 -0
  51. data/lib/noiseless/multi_search.rb +244 -0
  52. data/lib/noiseless/pagination.rb +375 -0
  53. data/lib/noiseless/query_builder.rb +284 -0
  54. data/lib/noiseless/railtie.rb +35 -0
  55. data/lib/noiseless/response/aggregations.rb +46 -0
  56. data/lib/noiseless/response/empty.rb +20 -0
  57. data/lib/noiseless/response/records.rb +94 -0
  58. data/lib/noiseless/response/results.rb +110 -0
  59. data/lib/noiseless/response/suggestions.rb +55 -0
  60. data/lib/noiseless/response.rb +98 -0
  61. data/lib/noiseless/response_factory.rb +32 -0
  62. data/lib/noiseless/runtime_reset_middleware.rb +15 -0
  63. data/lib/noiseless/search_index_update_job.rb +84 -0
  64. data/lib/noiseless/test_case.rb +230 -0
  65. data/lib/noiseless/test_helper.rb +295 -0
  66. data/lib/noiseless/version.rb +2 -2
  67. data/lib/noiseless.rb +130 -2
  68. data/lib/tasks/benchmark.rake +35 -0
  69. data/lib/tasks/release.rake +22 -0
  70. data/lib/tasks/test.rake +11 -0
  71. metadata +260 -14
@@ -0,0 +1,375 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ module Pagination
5
+ DEFAULT_PER_PAGE = 20
6
+ MAX_PER_PAGE = 100
7
+
8
+ # Simple paginated array wrapper - no external dependencies
9
+ class PaginatedArray < Array
10
+ attr_accessor :current_page, :per_page, :total_count
11
+
12
+ def initialize(records, current_page:, per_page:, total_count:)
13
+ super(records)
14
+ @current_page = current_page
15
+ @per_page = per_page
16
+ @total_count = total_count
17
+ end
18
+
19
+ def total_pages
20
+ return 1 if total_count.zero? || per_page.zero?
21
+
22
+ (total_count.to_f / per_page).ceil
23
+ end
24
+
25
+ def next_page
26
+ current_page < total_pages ? current_page + 1 : nil
27
+ end
28
+
29
+ def prev_page
30
+ current_page > 1 ? current_page - 1 : nil
31
+ end
32
+
33
+ def first_page?
34
+ current_page == 1
35
+ end
36
+
37
+ def last_page?
38
+ current_page >= total_pages
39
+ end
40
+
41
+ def out_of_range?
42
+ current_page > total_pages
43
+ end
44
+
45
+ def offset_value
46
+ (current_page - 1) * per_page
47
+ end
48
+
49
+ def limit_value
50
+ per_page
51
+ end
52
+
53
+ # JSON serialization for API responses
54
+ def pagination_metadata
55
+ {
56
+ current_page: current_page,
57
+ per_page: per_page,
58
+ total_count: total_count,
59
+ total_pages: total_pages,
60
+ next_page: next_page,
61
+ prev_page: prev_page
62
+ }
63
+ end
64
+ end
65
+
66
+ # Keyset pagination cursor
67
+ class Cursor
68
+ attr_reader :field, :value, :direction
69
+
70
+ def initialize(field:, value:, direction: :asc)
71
+ @field = field.to_s
72
+ @value = value
73
+ @direction = direction.to_sym
74
+ end
75
+
76
+ # Encode cursor for API response
77
+ def encode
78
+ Base64.urlsafe_encode64(JSON.generate({ f: field, v: value, d: direction }))
79
+ end
80
+
81
+ # Decode cursor from API request
82
+ def self.decode(encoded)
83
+ return nil if encoded.blank?
84
+
85
+ data = JSON.parse(Base64.urlsafe_decode64(encoded))
86
+ new(field: data["f"], value: data["v"], direction: data["d"]&.to_sym || :asc)
87
+ rescue StandardError
88
+ nil
89
+ end
90
+
91
+ # Build next cursor from last record
92
+ def self.from_record(record, field:, direction: :asc)
93
+ value = record.respond_to?(field) ? record.send(field) : record[field.to_s]
94
+ new(field: field, value: value, direction: direction)
95
+ end
96
+ end
97
+
98
+ # Keyset paginated result
99
+ class KeysetResult
100
+ attr_reader :records, :next_cursor, :has_more
101
+
102
+ def initialize(records, next_cursor: nil, has_more: false)
103
+ @records = records
104
+ @next_cursor = next_cursor
105
+ @has_more = has_more
106
+ end
107
+
108
+ def each(&)
109
+ records.each(&)
110
+ end
111
+
112
+ include Enumerable
113
+
114
+ def to_a
115
+ records
116
+ end
117
+
118
+ delegate :size, to: :records
119
+
120
+ delegate :empty?, to: :records
121
+
122
+ # JSON serialization for API responses
123
+ def pagination_metadata
124
+ {
125
+ has_more: has_more,
126
+ next_cursor: next_cursor&.encode
127
+ }
128
+ end
129
+ end
130
+
131
+ # Search paginator - builds and executes paginated queries
132
+ class SearchPaginator
133
+ include Enumerable
134
+
135
+ def initialize(model_class, page: 1, per_page: nil)
136
+ @model_class = model_class
137
+ @current_page = [page.to_i, 1].max
138
+ @per_page = [(per_page || DEFAULT_PER_PAGE).to_i, MAX_PER_PAGE].min
139
+ @query_builder = QueryBuilder.new(model_class)
140
+ @executed = false
141
+ @results = nil
142
+ end
143
+
144
+ def page(num)
145
+ SearchPaginator.new(@model_class, page: num, per_page: @per_page)
146
+ end
147
+
148
+ def per(num)
149
+ SearchPaginator.new(@model_class, page: @current_page, per_page: num)
150
+ end
151
+
152
+ # Pagination info
153
+ attr_reader :current_page
154
+
155
+ def limit_value
156
+ @per_page
157
+ end
158
+
159
+ def total_count
160
+ execute_search unless @executed
161
+ @total_count || 0
162
+ end
163
+
164
+ def total_pages
165
+ return 1 if total_count.zero?
166
+
167
+ (total_count.to_f / @per_page).ceil
168
+ end
169
+
170
+ def next_page
171
+ current_page < total_pages ? current_page + 1 : nil
172
+ end
173
+
174
+ def prev_page
175
+ current_page > 1 ? current_page - 1 : nil
176
+ end
177
+
178
+ def first_page?
179
+ current_page == 1
180
+ end
181
+
182
+ def last_page?
183
+ current_page >= total_pages
184
+ end
185
+
186
+ def out_of_range?
187
+ current_page > total_pages
188
+ end
189
+
190
+ def offset_value
191
+ (@current_page - 1) * @per_page
192
+ end
193
+
194
+ delegate :size, to: :to_a
195
+
196
+ def length
197
+ size
198
+ end
199
+
200
+ def empty?
201
+ size.zero?
202
+ end
203
+
204
+ # Enumerable interface
205
+ def each(&)
206
+ return enum_for(__method__) unless block_given?
207
+
208
+ to_a.each(&)
209
+ end
210
+
211
+ def to_a
212
+ execute_search unless @executed
213
+ @results.to_a
214
+ end
215
+
216
+ # Query building delegation
217
+ def match(field, value, **)
218
+ @query_builder.match(field, value, **)
219
+ self
220
+ end
221
+
222
+ def multi_match(query, fields, **)
223
+ @query_builder.multi_match(query, fields, **)
224
+ self
225
+ end
226
+
227
+ def filter(field, value, **)
228
+ @query_builder.filter(field, value, **)
229
+ self
230
+ end
231
+
232
+ def sort(field, direction = :asc, **)
233
+ @query_builder.sort(field, direction, **)
234
+ self
235
+ end
236
+
237
+ def aggregation(name, type, **)
238
+ @query_builder.aggregation(name, type, **)
239
+ self
240
+ end
241
+
242
+ def geo_distance(field, lat:, lon:, distance:, **)
243
+ @query_builder.geo_distance(field, lat: lat, lon: lon, distance: distance, **)
244
+ self
245
+ end
246
+
247
+ def vector(field, embedding, **)
248
+ @query_builder.vector(field, embedding, **)
249
+ self
250
+ end
251
+
252
+ # Response access
253
+ def results
254
+ execute_search unless @executed
255
+ @results
256
+ end
257
+
258
+ def aggregations
259
+ execute_search unless @executed
260
+ @results&.aggregations
261
+ end
262
+
263
+ def suggestions
264
+ execute_search unless @executed
265
+ @results&.suggestions
266
+ end
267
+
268
+ def hits
269
+ execute_search unless @executed
270
+ @results&.hits || []
271
+ end
272
+
273
+ def took
274
+ execute_search unless @executed
275
+ @results&.took
276
+ end
277
+
278
+ # Records-specific methods
279
+ def each_with_hit(&)
280
+ return enum_for(__method__) unless block_given?
281
+
282
+ execute_search unless @executed
283
+ if @results.respond_to?(:each_with_hit)
284
+ @results.each_with_hit(&)
285
+ else
286
+ to_a.each_with_index { |record, index| yield(record, hits[index]) }
287
+ end
288
+ end
289
+
290
+ def map_with_hit(&)
291
+ return enum_for(__method__) unless block_given?
292
+
293
+ each_with_hit.map(&)
294
+ end
295
+
296
+ # JSON metadata for API responses
297
+ def pagination_metadata
298
+ {
299
+ current_page: current_page,
300
+ per_page: @per_page,
301
+ total_count: total_count,
302
+ total_pages: total_pages,
303
+ next_page: next_page,
304
+ prev_page: prev_page
305
+ }
306
+ end
307
+
308
+ private
309
+
310
+ def execute_search
311
+ @query_builder.paginate(page: @current_page, per_page: @per_page)
312
+
313
+ client = Noiseless.connections.client(@model_class.connection)
314
+ ast = @query_builder.to_ast
315
+ @results = client.search(ast, model_class: @model_class)
316
+ @total_count = @results.total
317
+ @executed = true
318
+ end
319
+ end
320
+
321
+ # Extend response classes with pagination support
322
+ module ResponsePagination
323
+ def total_pages
324
+ return 1 if total.zero? || @per_page.nil?
325
+
326
+ (total.to_f / @per_page).ceil
327
+ end
328
+
329
+ def current_page
330
+ return 1 unless @from && @per_page
331
+
332
+ (@from / @per_page) + 1
333
+ end
334
+
335
+ def next_page
336
+ current_page < total_pages ? current_page + 1 : nil
337
+ end
338
+
339
+ def prev_page
340
+ current_page > 1 ? current_page - 1 : nil
341
+ end
342
+
343
+ def first_page?
344
+ current_page == 1
345
+ end
346
+
347
+ def last_page?
348
+ current_page >= total_pages
349
+ end
350
+
351
+ def out_of_range?
352
+ current_page > total_pages
353
+ end
354
+
355
+ def limit_value
356
+ @per_page
357
+ end
358
+
359
+ def offset_value
360
+ @from || 0
361
+ end
362
+
363
+ def pagination_metadata
364
+ {
365
+ current_page: current_page,
366
+ per_page: @per_page,
367
+ total_count: total,
368
+ total_pages: total_pages,
369
+ next_page: next_page,
370
+ prev_page: prev_page
371
+ }
372
+ end
373
+ end
374
+ end
375
+ end
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ class QueryBuilder
5
+ def initialize(model)
6
+ @model = model
7
+ @indexes = determine_indexes(model)
8
+ @nodes = []
9
+ @aggregations = []
10
+ @collapse = nil
11
+ @search_after = nil
12
+ @hybrid = nil
13
+ @pipeline = nil
14
+ @image_query = nil
15
+ @conversation = nil
16
+ @joins = []
17
+ @remove_duplicates = nil
18
+ @facet_sample_slope = nil
19
+ @pinned_hits = nil
20
+ end
21
+
22
+ def indexes(names)
23
+ @indexes = Array(names).map(&:to_s)
24
+ self
25
+ end
26
+
27
+ def match(field, value)
28
+ @nodes << AST::Match.new(field, value)
29
+ self
30
+ end
31
+
32
+ def multi_match(query, fields, **)
33
+ @nodes << AST::MultiMatch.new(query, fields, **)
34
+ self
35
+ end
36
+
37
+ def wildcard(field, value)
38
+ @nodes << AST::Wildcard.new(field, value)
39
+ self
40
+ end
41
+
42
+ def range(field, gte: nil, lte: nil, gt: nil, lt: nil)
43
+ @nodes << AST::Range.new(field, gte: gte, lte: lte, gt: gt, lt: lt)
44
+ self
45
+ end
46
+
47
+ def prefix(field, value)
48
+ @nodes << AST::Prefix.new(field, value)
49
+ self
50
+ end
51
+
52
+ def filter(field, value)
53
+ @nodes << AST::Filter.new(field, value)
54
+ self
55
+ end
56
+
57
+ alias where filter
58
+
59
+ def sort(field, dir = :asc)
60
+ @nodes << AST::Sort.new(field, dir)
61
+ self
62
+ end
63
+
64
+ alias order sort
65
+
66
+ def paginate(page: 1, per_page: 20)
67
+ @nodes << AST::Paginate.new(page, per_page)
68
+ self
69
+ end
70
+
71
+ def limit(size)
72
+ @nodes << AST::Paginate.new(1, size)
73
+ self
74
+ end
75
+
76
+ def offset(from)
77
+ # Calculate page based on offset and current per_page
78
+ existing_paginate = @nodes.find { |n| n.is_a?(AST::Paginate) }
79
+ per_page = existing_paginate&.per_page || 20
80
+ page = (from / per_page) + 1
81
+ @nodes.reject! { |n| n.is_a?(AST::Paginate) }
82
+ @nodes << AST::Paginate.new(page, per_page)
83
+ self
84
+ end
85
+
86
+ def aggregation(name, type, field: nil, **, &)
87
+ sub_aggs = []
88
+ if block_given?
89
+ sub_builder = AST::AggregationBuilder.new
90
+ sub_builder.instance_eval(&)
91
+ sub_aggs = sub_builder.aggregations
92
+ end
93
+
94
+ @aggregations << AST::Aggregation.new(name, type, field: field, sub_aggregations: sub_aggs, **)
95
+ self
96
+ end
97
+
98
+ alias agg aggregation
99
+
100
+ def collapse(field, inner_hits: nil, max_concurrent_group_searches: nil)
101
+ @collapse = AST::Collapse.new(field, inner_hits: inner_hits,
102
+ max_concurrent_group_searches: max_concurrent_group_searches)
103
+ self
104
+ end
105
+
106
+ def search_after(values)
107
+ @search_after = AST::SearchAfter.new(values)
108
+ self
109
+ end
110
+
111
+ def combined_fields(query, fields, operator: nil, minimum_should_match: nil, **)
112
+ @nodes << AST::CombinedFields.new(query, fields, operator: operator, minimum_should_match: minimum_should_match,
113
+ **)
114
+ self
115
+ end
116
+
117
+ def geo_distance(field, lat:, lon:, distance:, **options)
118
+ # Create a special geo filter node
119
+ geo_filter = AST::Filter.new(field, {
120
+ geo_distance: {
121
+ distance: distance,
122
+ "#{field}": { lat: lat, lon: lon }
123
+ }.merge(options)
124
+ })
125
+ @nodes << geo_filter
126
+ self
127
+ end
128
+
129
+ # Vector/semantic search using embeddings (pgvector or OpenSearch knn)
130
+ # @param field [Symbol] The embedding column/field
131
+ # @param embedding [Array<Float>] The query embedding vector
132
+ # @param k [Integer] Number of nearest neighbors (default: 10)
133
+ # @param distance_metric [Symbol] :cosine, :l2, or :inner_product
134
+ def vector(field, embedding, k: 10, distance_metric: :cosine)
135
+ @nodes << AST::Vector.new(field, embedding, k: k, distance_metric: distance_metric)
136
+ self
137
+ end
138
+
139
+ alias knn vector
140
+ alias semantic_search vector
141
+
142
+ # Hybrid search combining text query with vector search
143
+ # @param text_query [String] The text query for BM25 matching
144
+ # @param embedding [Array<Float>] The query embedding vector
145
+ # @param field [Symbol] The embedding field name
146
+ # @param text_weight [Float] Weight for text search score (default: 0.5)
147
+ # @param vector_weight [Float] Weight for vector search score (default: 0.5)
148
+ # @param k [Integer] Number of nearest neighbors (default: 10)
149
+ def hybrid(text_query, embedding, field:, text_weight: 0.5, vector_weight: 0.5, k: 10)
150
+ vector_node = AST::Vector.new(field, embedding, k: k)
151
+ @hybrid = AST::Hybrid.new(text_query, vector_node, text_weight: text_weight, vector_weight: vector_weight)
152
+ self
153
+ end
154
+
155
+ # Apply a search pipeline (OpenSearch only)
156
+ # @param pipeline_name [String] Name of the search pipeline to use
157
+ def pipeline(pipeline_name)
158
+ @pipeline = pipeline_name
159
+ self
160
+ end
161
+
162
+ # Image search using visual similarity (Typesense only)
163
+ # @param field [Symbol] The image embedding field name
164
+ # @param image_data [String] Image URL or base64 encoded image
165
+ # @param k [Integer] Number of nearest neighbors (default: 10)
166
+ def image_search(field, image_data, k: 10)
167
+ @image_query = AST::ImageQuery.new(field, image_data, k: k)
168
+ self
169
+ end
170
+
171
+ # Conversational/RAG search (Typesense and Elasticsearch)
172
+ # @param model_id [String] The LLM model identifier
173
+ # @param conversation_id [String, nil] ID for multi-turn conversations
174
+ # @param system_prompt [String, nil] Custom system prompt
175
+ def conversational(model_id:, conversation_id: nil, system_prompt: nil)
176
+ @conversation = AST::Conversation.new(
177
+ model_id: model_id,
178
+ conversation_id: conversation_id,
179
+ system_prompt: system_prompt
180
+ )
181
+ self
182
+ end
183
+
184
+ alias rag conversational
185
+
186
+ # Join with another collection (Typesense only)
187
+ # @param collection [String, Symbol] The collection to join
188
+ # @param on [Hash] Join conditions
189
+ # @param include_fields [Array] Fields to include from joined collection
190
+ # @param strategy [Symbol] Join strategy :left or :inner
191
+ def join(collection, on:, include_fields: [], strategy: :left)
192
+ @joins << AST::Join.new(collection, on: on, include_fields: include_fields, strategy: strategy)
193
+ self
194
+ end
195
+
196
+ # Remove duplicate documents in Typesense union search results.
197
+ def remove_duplicates(value: true)
198
+ @remove_duplicates = if value.nil?
199
+ nil
200
+ else
201
+ value ? true : false
202
+ end
203
+ self
204
+ end
205
+
206
+ # Controls dynamic facet sampling behavior in Typesense.
207
+ def facet_sample_slope(value)
208
+ @facet_sample_slope = value
209
+ self
210
+ end
211
+
212
+ # Pin specific document IDs to fixed result positions in Typesense.
213
+ #
214
+ # Supported formats:
215
+ # - String: "id1:1,id2:2"
216
+ # - Hash: { "id1" => 1, "id2" => 2 }
217
+ # - Array of pairs: [["id1", 1], ["id2", 2]]
218
+ def pinned_hits(value)
219
+ @pinned_hits = normalize_pinned_hits(value)
220
+ self
221
+ end
222
+
223
+ def to_ast
224
+ filter_nodes = @nodes.select { |n| n.is_a?(AST::Filter) }
225
+ vector_nodes = @nodes.select { |n| n.is_a?(AST::Vector) }
226
+ must_nodes = @nodes.reject do |n|
227
+ n.is_a?(AST::Filter) || n.is_a?(AST::Sort) || n.is_a?(AST::Paginate) || n.is_a?(AST::Vector)
228
+ end
229
+ bool_node = AST::Bool.new(must: must_nodes, filter: filter_nodes)
230
+ sort_nodes = @nodes.select { |n| n.is_a?(AST::Sort) }
231
+ paginate_node = @nodes.find { |n| n.is_a?(AST::Paginate) }
232
+ AST::Root.new(
233
+ indexes: @indexes,
234
+ bool: bool_node,
235
+ sort: sort_nodes,
236
+ paginate: paginate_node,
237
+ vector: vector_nodes.first, # Only support one vector search per query for now
238
+ collapse: @collapse,
239
+ search_after: @search_after,
240
+ aggregations: @aggregations,
241
+ hybrid: @hybrid,
242
+ pipeline: @pipeline,
243
+ image_query: @image_query,
244
+ conversation: @conversation,
245
+ joins: @joins,
246
+ remove_duplicates: @remove_duplicates,
247
+ facet_sample_slope: @facet_sample_slope,
248
+ pinned_hits: @pinned_hits
249
+ )
250
+ end
251
+
252
+ private
253
+
254
+ def normalize_pinned_hits(value)
255
+ case value
256
+ when nil
257
+ nil
258
+ when String
259
+ value
260
+ when Hash
261
+ value.map { |id, position| "#{id}:#{position}" }.join(",")
262
+ when Array
263
+ value.map do |entry|
264
+ raise ArgumentError, "pinned_hits array entries must be [id, position]" unless entry.is_a?(Array) && entry.size == 2
265
+
266
+ "#{entry[0]}:#{entry[1]}"
267
+ end.join(",")
268
+ else
269
+ raise ArgumentError, "pinned_hits must be a String, Hash, or Array of [id, position]"
270
+ end
271
+ end
272
+
273
+ def determine_indexes(model)
274
+ # Check for search_index (plural array) first
275
+ return Array(model.search_index) if model.respond_to?(:search_index) && model.search_index&.any?
276
+
277
+ # Check for index_name (singular string) next
278
+ return [model.index_name] if model.respond_to?(:index_name) && model.index_name
279
+
280
+ # Fallback to pluralized model name
281
+ [model.name.demodulize.underscore.pluralize]
282
+ end
283
+ end
284
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+ require_relative "instrumentation"
5
+ require_relative "runtime_reset_middleware"
6
+
7
+ module Noiseless
8
+ class Railtie < Rails::Railtie
9
+ railtie_name :noiseless
10
+
11
+ config.noiseless = ActiveSupport::OrderedOptions.new
12
+
13
+ initializer "noiseless.configure" do |_app|
14
+ # Load configuration from config/noiseless.yml
15
+ Noiseless.load_configuration!
16
+ end
17
+
18
+ initializer "noiseless.instrumentation" do |app|
19
+ # Attach log subscriber
20
+ Noiseless::LogSubscriber.attach_to :noiseless
21
+
22
+ # Include controller runtime tracking in ActionController
23
+ ActiveSupport.on_load(:action_controller) do
24
+ include Noiseless::ControllerRuntime
25
+ end
26
+
27
+ # Reset runtime tracking at the beginning of each request
28
+ app.middleware.use Noiseless::RuntimeResetMiddleware
29
+ end
30
+
31
+ generators do
32
+ require_relative "generators/application_search_generator"
33
+ end
34
+ end
35
+ end