Module: Models::Publication

Extended by:
ActiveSupport::Concern
Includes:
Memery, HybridSearchable
Included in:
Item
Defined in:
app/concerns/models/publication.rb

Defined Under Namespace

Modules: ClassMethods

Instance Attribute Summary collapse

Has many collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from HybridSearchable

rrf_ranked_relation

Instance Attribute Details

#available_in_canadaObject

memoize :available_in_usa



211
212
213
# File 'app/concerns/models/publication.rb', line 211

def available_in_canada
  available_service_locales.intersect?(%i[en-CA fr-CA])
end

#available_in_usaObject



206
207
208
# File 'app/concerns/models/publication.rb', line 206

def available_in_usa
  available_service_locales.include?(:'en-US')
end

#serve_in_localeObject

Returns the value of attribute serve_in_locale.



10
11
12
# File 'app/concerns/models/publication.rb', line 10

def serve_in_locale
  @serve_in_locale
end

Class Method Details

.ai_search_publicationsActiveRecord::Relation<Models::Publication>

A relation of Models::Publications that are ai search publications. Active Record Scope

Returns:

See Also:



63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'app/concerns/models/publication.rb', line 63

scope :ai_search_publications, ->(query, limit: 200, max_distance: nil) {
  # Clear any previous warning
  self.last_ai_search_warning = nil

  return none if query.blank?

  # Generate query embedding using OpenAI (same model as stored publication embeddings)
  # NOTE: Publications use text-embedding-3-small, NOT Gemini like Images do
  query_embedding = generate_openai_query_embedding(query)
  unless query_embedding
    self.last_ai_search_warning = 'Could not generate AI embedding for your query. Please try a different search term.'
    return none
  end

  # Format vector for SQL with explicit dimension cast
  dimensions = query_embedding.size
  vector_literal = "[#{query_embedding.join(',')}]"

  # Build raw SQL for minimum distance per item across all embeddings (primary + chunks)
  # Using raw SQL with from() to avoid ActiveRecord query manipulation issues with .count
  min_distance_sql = sanitize_sql_array([
                                          <<~SQL.squish,
                                            SELECT embeddable_id,
                                                   MIN(embedding::vector(#{dimensions}) <=> ?::vector(#{dimensions})) AS min_distance
                                            FROM content_embeddings_items
                                            WHERE embeddable_type = 'Item'
                                              AND (content_type = 'primary' OR content_type LIKE 'primary_chunk_%')
                                              AND embedding IS NOT NULL
                                            GROUP BY embeddable_id
                                          SQL
                                          vector_literal
                                        ])

  # Use where(id: subquery) pattern which works better with count
  matching_ids_sql = "SELECT embeddable_id FROM (#{min_distance_sql}) AS distances"
  matching_ids_sql += sanitize_sql_array([' WHERE min_distance < ?', max_distance]) if max_distance.present?

  # Get matching item IDs and order by distance
  base_query = where("#{table_name}.id IN (#{matching_ids_sql})")
               .joins("INNER JOIN (#{min_distance_sql}) AS best_match ON best_match.embeddable_id = #{table_name}.id")
               .select("#{table_name}.*", 'best_match.min_distance AS neighbor_distance')
               .order('best_match.min_distance ASC')

  base_query.limit(limit)
}

.ai_search_warning?Boolean

Check if the last AI search had issues (call from controller after running scope)

Returns:

  • (Boolean)


54
55
56
# File 'app/concerns/models/publication.rb', line 54

def self.ai_search_warning?
  last_ai_search_warning.present?
end

.clear_ai_search_warning!Object

Clear the AI search warning



59
60
61
# File 'app/concerns/models/publication.rb', line 59

def self.clear_ai_search_warning!
  self.last_ai_search_warning = nil
end

.embeddable_content_typesObject

Content types for publication embeddings



456
457
458
# File 'app/concerns/models/publication.rb', line 456

def self.embeddable_content_types
  [:primary]
end

.generate_openai_query_embedding(query, model: 'text-embedding-3-small') ⇒ Array<Float>?

Generate query embedding using OpenAI text-embedding-3-small
This matches the model used for stored publication embeddings
(Different from Images which use Gemini Embedding 2)

Parameters:

  • query (String)

    The search query to embed

  • model (String) (defaults to: 'text-embedding-3-small')

    The embedding model to use

Returns:

  • (Array<Float>, nil)

    The embedding vector, or nil on failure



130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'app/concerns/models/publication.rb', line 130

def self.generate_openai_query_embedding(query, model: 'text-embedding-3-small')
  return nil if query.blank?

  cache_key = "query_embedding:openai:#{model}:#{Digest::SHA256.hexdigest(query.downcase.strip)[0..15]}"

  # Try cache first
  cached = Rails.cache.read(cache_key)
  return cached if cached.present?

  # Use RubyLLM to generate embedding with OpenAI
  # RubyLLM.embed returns an Embedding object where .vectors is the array of floats
  result = RubyLLM.embed(query, model: model, provider: :openai, assume_model_exists: true)
  vector = result&.vectors # vectors IS the 1536-dimension array, not an array of arrays

  # Cache for 24 hours
  Rails.cache.write(cache_key, vector, expires_in: 24.hours) if vector.present?

  vector
rescue RubyLLM::RateLimitError => e
  Rails.logger.warn "Rate limited generating publication query embedding: #{e.message}"
  nil
rescue RubyLLM::Error => e
  Rails.logger.error "RubyLLM error generating publication query embedding: #{e.message}"
  nil
rescue StandardError => e
  Rails.logger.error "Failed to generate OpenAI query embedding: #{e.message}"
  ErrorReporting.warning(e, context: { query: query.truncate(100), model: model }, reason: 'embedding_generation_failed')
  nil
end

.hybrid_search_publicationsActiveRecord::Relation<Models::Publication>

A relation of Models::Publications that are hybrid search publications. Active Record Scope

Returns:

See Also:



110
111
112
113
114
115
116
117
118
119
120
121
# File 'app/concerns/models/publication.rb', line 110

scope :hybrid_search_publications, ->(query, limit: 200) {
  return none if query.blank?

  ai_ids = ai_search_publications(query, limit: limit).pluck(:id)
  keyword_ids = begin
    keywords_search(query).limit(limit).pluck(:id)
  rescue PgSearch::EmptyQueryError
    []
  end

  rrf_ranked_relation(ai_ids, keyword_ids, limit: limit)
}

.publicationsActiveRecord::Relation<Models::Publication>

A relation of Models::Publications that are publications. Active Record Scope

Returns:

See Also:



18
# File 'app/concerns/models/publication.rb', line 18

scope :publications, -> { where(arel_table[:pc_path_slugs].ltree_descendant(LtreePaths::PC_PUBLICATIONS)) }

.publications_for_online_portalActiveRecord::Relation<Models::Publication>

A relation of Models::Publications that are publications for online portal. Active Record Scope

Returns:

See Also:



25
# File 'app/concerns/models/publication.rb', line 25

scope :publications_for_online_portal, -> { publications.active.where(product_category_id: ProductCategory.where(show_in_sales_portal: true).or(ProductCategory.where(show_in_support_portal: true)).select(:id)) }

.publications_for_publicActiveRecord::Relation<Models::Publication>

A relation of Models::Publications that are publications for public. Active Record Scope

Returns:

See Also:



21
# File 'app/concerns/models/publication.rb', line 21

scope :publications_for_public, -> { publications_for_public_in_store(Store::PUBLIC_STORE_IDS) }

.publications_for_public_in_storeActiveRecord::Relation<Models::Publication>

A relation of Models::Publications that are publications for public in store. Active Record Scope

Returns:

See Also:



20
# File 'app/concerns/models/publication.rb', line 20

scope :publications_for_public_in_store, ->(*store_ids) { publications.with_publication_attached.active.in_store(store_ids) }

.publications_for_sales_portalActiveRecord::Relation<Models::Publication>

A relation of Models::Publications that are publications for sales portal. Active Record Scope

Returns:

See Also:



24
# File 'app/concerns/models/publication.rb', line 24

scope :publications_for_sales_portal, -> { publications.active.where(product_category_id: ProductCategory.for_sales_portal.select(:id)) }

.publications_for_support_portalActiveRecord::Relation<Models::Publication>

A relation of Models::Publications that are publications for support portal. Active Record Scope

Returns:

See Also:



23
# File 'app/concerns/models/publication.rb', line 23

scope :publications_for_support_portal, -> { publications.active.where(product_category_id: ProductCategory.for_support_portal.select(:id)) }

.with_publication_attachedActiveRecord::Relation<Models::Publication>

A relation of Models::Publications that are with publication attached. Active Record Scope

Returns:

See Also:



19
# File 'app/concerns/models/publication.rb', line 19

scope :with_publication_attached, -> { joins(:literature).includes(:literature) }

Instance Method Details

#alias_skuObject

Store the new sku in our alias chain



380
381
382
383
384
385
386
387
# File 'app/concerns/models/publication.rb', line 380

def alias_sku
  return unless is_publication?

  self.sku_aliases ||= []
  self.sku_aliases.delete(sku)
  self.sku_aliases << sku_was if sku_changed?
  self.sku_aliases = self.sku_aliases.filter_map(&:presence).uniq
end

#analyze_pdf_images!(force: false) ⇒ Object

Queue vision analysis for this publication's PDF images



525
526
527
528
529
# File 'app/concerns/models/publication.rb', line 525

def analyze_pdf_images!(force: false)
  return unless is_publication?

  PublicationVisionWorker.perform_async(id, force: force)
end

#available_service_localesObject



198
199
200
# File 'app/concerns/models/publication.rb', line 198

def available_service_locales
  store_items.active.eager_load(store: :country).map { |si| si.store.locales_served }.flatten.uniq & LocaleUtility.service_locales
end

#available_to_all_locales?Boolean

Returns:

  • (Boolean)


202
203
204
# File 'app/concerns/models/publication.rb', line 202

def available_to_all_locales?
  available_service_locales.size == LocaleUtility.service_locales.size
end

#check_redirection_pathObject



299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
# File 'app/concerns/models/publication.rb', line 299

def check_redirection_path
  return if redirection_path.blank?

  # Check that our  path is a valid uri
  begin
    uri = Addressable::URI.parse(redirection_path)
    # canonicalize our url
    uri.path = uri.path.gsub(%r{^/(en|fr)-(US|CA)}, '')
    uri.host = WEB_HOSTNAME_WITHOUT_PORT
    uri.port = APP_PORT_NUMBER unless APP_PORT_NUMBER == 80
    uri.scheme = 'https'
    self.redirection_path = uri.to_s
  rescue StandardError => e
    errors.add(:redirection_path, "redirection path could not be parsed, #{e}")
  end
end

#compose_name_with_languagesObject



224
225
226
227
228
229
230
231
232
# File 'app/concerns/models/publication.rb', line 224

def compose_name_with_languages
  return if publication_base_name.blank?

  n = publication_base_name.dup
  if (fln = friendly_locale_names).present?
    n << " (#{fln.join(', ')})"
  end
  n
end

#content_for_embedding(_content_type = :primary) ⇒ Object

Generate content for semantic search embedding
Uses extracted PDF text plus metadata



462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
# File 'app/concerns/models/publication.rb', line 462

def content_for_embedding(_content_type = :primary)
  return nil unless is_publication?

  parts = []

  # Title and basic info
  parts << "Publication: #{publication_base_name}" if publication_base_name.present?
  parts << "SKU: #{sku}" if sku.present?

  # Product line context (what product is this documentation for?)
  parts << "Product Line: #{primary_product_line.lineage_expanded}" if primary_product_line.present?

  if product_lines.any?
    other_pls = product_lines.reject { |pl| pl == primary_product_line }
    parts << "Related Products: #{other_pls.map(&:name).join(', ')}" if other_pls.any?
  end

  # Category context (installation manual, datasheet, etc.)
  parts << "Type: #{product_category.name}" if product_category.present?

  # Languages
  parts << "Languages: #{friendly_locale_names.join(', ')}" if friendly_locale_names.any?

  # Curator-supplied search keywords (boost discoverability for known query terms)
  parts << "Keywords: #{search_keywords}" if search_keywords.present?

  # Claude's full-PDF analysis is the primary content source when available.
  # It covers text, diagrams, tables, and illustrations in a clean structured form,
  # making raw text extraction redundant (which tends to be garbled/concatenated).
  # Fall back to search_text only when no Claude analysis has been run yet.
  if pdf_image_descriptions.present?
    parts << "Content:\n#{pdf_image_descriptions}"
  elsif search_text.present?
    parts << "Content:\n#{search_text}"
  elsif literature.present?
    extracted = retrieve_publications_search_text
    parts << "Content:\n#{extracted}" if extracted.present?
  end

  parts.compact.join("\n\n")
end

#cover_image_url(options = {}) ⇒ Object

Pulls the primary image or generates one from the PDF if needed



400
401
402
403
404
405
406
407
408
409
# File 'app/concerns/models/publication.rb', line 400

def cover_image_url(options = {})
  img_options = options.dup.symbolize_keys
  image = primary_image
  image ||= create_primary_image_from_pdf if img_options.delete(:create_if_missing)
  return unless image

  img_options[:format] ||= 'jpeg'
  img_options[:width] ||= 1200 if img_options[:size].blank?
  image.image_url(img_options)
end

#create_primary_image_from_pdfObject

Takes the PDF and generates a cover image which will be saved to the image library



412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
# File 'app/concerns/models/publication.rb', line 412

def create_primary_image_from_pdf
  # Extract literature to file
  pdf_path = begin
    literature.attachment.path
  rescue StandardError
    nil
  end
  if pdf_path.nil? || !File.exist?(pdf_path)
    logger.error "Could not pull pdf for #{sku} to generate thumbnail"
    return
  end
  logger.info "Generating thumbnail for #{sku} from PDF path #{pdf_path} with name #{name}"
  if (image = Pdf::Utility::ImageCreator.new.process(pdf_path:, name:))
    # destroy prior image if it exists
    primary_image&.destroy
    # link new one
    update_column(:primary_image_id, image.id)
  end
  image
end

#default_publication_logisticsObject

Set sensible defaults for logistics, in case this publication has to ship



390
391
392
393
394
395
396
397
# File 'app/concerns/models/publication.rb', line 390

def default_publication_logistics
  # we use a 0.05 default for weight, assuming a 5 piece regular stock paper 2000#
  # We also sort because base_weight should always be smaller than shipping weight
  self.base_weight, self.shipping_weight = [base_weight || 0.05, shipping_weight || base_weight || 0.05].sort
  self.shipping_width ||= 9
  self.shipping_length ||= 12
  self.shipping_height ||= 0.1
end

#embeddable_localesObject

For publications in multiple languages, return all locales



547
548
549
550
551
552
# File 'app/concerns/models/publication.rb', line 547

def embeddable_locales
  return ['en'] unless is_publication?

  locales = publication_locales.presence || ['en']
  locales.map(&:to_s).uniq
end

#embedding_content_changed?Boolean

Publications with PDF changes should regenerate embeddings

Returns:

  • (Boolean)


505
506
507
508
509
510
511
512
513
514
# File 'app/concerns/models/publication.rb', line 505

def embedding_content_changed?
  return false unless is_publication?

  saved_change_to_search_text? ||
    saved_change_to_search_keywords? ||
    saved_change_to_publication_base_name? ||
    saved_change_to_primary_product_line_id? ||
    saved_change_to_pdf_image_descriptions? ||
    has_literature_changed?
end

#fast_country_discontinueObject

Shortcut method when we deal with publications



322
323
324
325
326
327
328
329
330
331
332
# File 'app/concerns/models/publication.rb', line 322

def fast_country_discontinue
  unless @available_in_usa.nil?
    discontinue_usa = is_discontinued || !@available_in_usa.to_b
    perform_country_discontinue(1, discontinue_usa)
  end

  return if @available_in_canada.nil?

  discontinue_can = is_discontinued || !@available_in_canada.to_b
  perform_country_discontinue(2, discontinue_can)
end

#file_name_for_download(file_extension = nil) ⇒ Object



234
235
236
237
238
239
240
# File 'app/concerns/models/publication.rb', line 234

def file_name_for_download(file_extension = nil)
  return unless literature

  file_extension ||= Rack::Mime::MIME_TYPES.invert[literature.mime_type]
  file_extension ||= '.pdf'
  "#{name.parameterize}-#{id}#{file_extension}"
end

#friendly_locale_namesObject



194
195
196
# File 'app/concerns/models/publication.rb', line 194

def friendly_locale_names
  (publication_locales || []).map { |l| LocaleUtility.language_name(l) }.uniq.compact
end

#generate_publication_nameObject

Embeds languages in name



243
244
245
246
247
248
# File 'app/concerns/models/publication.rb', line 243

def generate_publication_name
  self.publication_locales = ['en'] if publication_locales.blank?
  self.name_en = compose_name_with_languages
  self.name_en_us = nil
  self.name_en_ca = nil
end

#has_literature_changed?Boolean

Returns:

  • (Boolean)


433
434
435
436
437
# File 'app/concerns/models/publication.rb', line 433

def has_literature_changed?
  return false unless literature

  literature.saved_change_to_attachment_uid? || literature.attachment_uid_changed?
end

#item_embeddingsActiveRecord::Relation<ContentEmbedding::ItemEmbedding>

Association to the partitioned embeddings table for publication AI search

Returns:

See Also:



28
29
30
# File 'app/concerns/models/publication.rb', line 28

has_many :item_embeddings, -> { where(embeddable_type: 'Item') },
class_name: 'ContentEmbedding::ItemEmbedding',
foreign_key: :embeddable_id

#locale_for_embeddingObject

Locale for embedding - uses publication_locales
Publications can be in multiple languages, returns the first/primary



540
541
542
543
544
# File 'app/concerns/models/publication.rb', line 540

def locale_for_embedding
  return 'en' unless is_publication?

  publication_locales&.first.to_s.presence || 'en'
end

#needs_vision_analysis?Boolean

Check if vision analysis is needed for this publication

Returns:

  • (Boolean)


517
518
519
520
521
522
# File 'app/concerns/models/publication.rb', line 517

def needs_vision_analysis?
  return false unless is_publication?
  return false unless literature&.attachment_stored?

  pdf_images_analyzed_at.blank?
end

#perform_country_discontinue(store_id, discontinue) ⇒ Object



334
335
336
337
338
339
340
341
342
343
344
345
346
347
# File 'app/concerns/models/publication.rb', line 334

def perform_country_discontinue(store_id, discontinue)
  si = store_items.where(store_id: store_id).first_or_initialize(unit_cogs: 0, qty_on_hand: 0, qty_committed: 0, handling_charge: 0, location: 'AVAILABLE')
  if discontinue
    unless si.new_record?
      si.catalog_items.each(&:discontinue)
      si.is_discontinued = true
      si.save
    end
  else
    si.is_discontinued = false
    si.save
    si.catalog_items.each(&:activate) # implicit unhide
  end
end

#product_category_must_be_publicationObject



182
183
184
185
186
# File 'app/concerns/models/publication.rb', line 182

def product_category_must_be_publication
  return if product_category&.is_publication?

  errors.add(:product_category_id, 'cannot be a non-publication category')
end

#publication_available_localesObject

memoize :available_in_canada



216
217
218
# File 'app/concerns/models/publication.rb', line 216

def publication_available_locales
  available_service_locales
end

#publication_base_name_clean_of_languageObject



250
251
252
253
254
255
256
257
# File 'app/concerns/models/publication.rb', line 250

def publication_base_name_clean_of_language
  words_list = publication_base_name.to_s.downcase.split(/(\w+)/).map { |l| l if l.present? && l.length > 3 }.uniq.compact
  languages = LocaleUtility.available_languages.map(&:downcase)
  matches = words_list & languages
  return if matches.blank?

  errors.add(:publication_base_name, "should not include the language: #{matches.join(', ')}, this is auto generated if you specify it in locale/language")
end

#publication_pdf_changed?Boolean

Returns:

  • (Boolean)


439
440
441
# File 'app/concerns/models/publication.rb', line 439

def publication_pdf_changed?
  saved_change_to_literature_id? || has_literature_changed?
end

#publication_sku_checkObject

Check that our publication is formatted with the version



260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
# File 'app/concerns/models/publication.rb', line 260

def publication_sku_check
  return true unless product_category.present? && is_publication?
  return true if sku.blank? && publication_base_name.blank?

  self.sku ||= publication_base_name&.parameterize&.upcase&.gsub(/[^a-zA-Z0-9-]/, '-')&.squeeze('-')

  original_sku = sku
  sku_match = sku.match(/(.*)-([[:alpha:]])$/)
  # Upcase by default
  res = false
  if sku_match && (sku_match.length == 3)
    self.sku = "#{sku_match[1]}-#{sku_match[2]}"
    sku_match[1]
    res = true
  elsif sku.present?
    # implicit -A
    sku
    self.sku = "#{sku}-A"
    res = true
  else
    errors.add(:sku, 'for publications must be formatted with a trailing alphabetical revision indicator, such as -a, -b, etc.')
  end
  self.sku = sku.upcase.gsub(/[_ ]/, '-').squeeze('-')
  if (public_short_name == original_sku) || (public_short_name == name)
    self.public_short_name = nil # redundant
  end
  if detailed_description_html == original_sku
    self.detailed_description_html = nil # useless, we want real description
  end
  res
end

#publication_urlObject



373
374
375
376
377
# File 'app/concerns/models/publication.rb', line 373

def publication_url
  return unless is_publication?

  "https://#{WEB_HOSTNAME}/publications/#{sku}"
end

#publication_visible_to_public?Boolean

Returns:

  • (Boolean)


220
221
222
# File 'app/concerns/models/publication.rb', line 220

def publication_visible_to_public?
  literature.present? && available_service_locales.present?
end

#publish_pdf_changed_eventObject



443
444
445
446
447
448
# File 'app/concerns/models/publication.rb', line 443

def publish_pdf_changed_event
  Rails.configuration.event_store.publish(
    Events::PublicationPdfChanged.new(data: { item_id: id }),
    stream_name: "Publication-#{id}"
  )
end

#retrieve_publications_search_textObject



349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
# File 'app/concerns/models/publication.rb', line 349

def retrieve_publications_search_text
  return unless is_publication?
  return unless literature&.attachment
  return unless File.exist?(literature.attachment.path)
  return unless literature.attachment.format == 'pdf'

  begin
    require 'pdf/reader'
    reader = PDF::Reader.new(literature.attachment.path)
    content = reader.pages.map(&:text).join("\n")
    content = content.squish
    content = content.squeeze('_') # removes ______
    content = content.squeeze('.') # removes .......
    content = content.squeeze('-') # removes .......
    content = content.gsub(/ [_-]/, ' ')
    content = content.gsub(/[":•]/, '')
    content = content.squish
    ActionController::Base.helpers.sanitize(content) # sometimes we get crap so we have to do this, e.g. null string
  rescue StandardError => e
    logger.error "Unable to parse pdf content for publication id #{id}"
    logger.error e.inspect
  end
end

#secondary_product_category_must_not_be_publicationObject



188
189
190
191
192
# File 'app/concerns/models/publication.rb', line 188

def secondary_product_category_must_not_be_publication
  return unless secondary_product_category&.is_publication?

  errors.add(:secondary_product_category_id, 'cannot be a publication category')
end

#set_search_textObject



292
293
294
295
296
297
# File 'app/concerns/models/publication.rb', line 292

def set_search_text
  content = retrieve_publications_search_text
  return if content.blank?

  update_column(:search_text, retrieve_publications_search_text)
end

#should_queue_embedding?Boolean

Only embed active, public publications

Returns:

  • (Boolean)


532
533
534
535
536
# File 'app/concerns/models/publication.rb', line 532

def should_queue_embedding?
  return false unless is_publication?

  super && !is_discontinued? && publication_visible_to_public?
end