Class: Publication::TextSearch

Inherits:
Object
  • Object
show all
Defined in:
app/services/publication/text_search.rb

Overview

Discovery half of cross-publication find-and-replace ("find every PDF whose
spec says X"): searches the docling-extracted text cache
(+uploads.extracted_markdown+) of every publication's literature for a
literal phrase and reports the blast radius — a COUNT and a sample — before
anything is enqueued (the bulk-op rule: count first, run second).

Notes:

  • Matches the docling extraction, NOT the raw PDF text layer. A publication
    whose literature was never extracted won't be found (run
    UploadMarkdownExtractionWorker / the backfill script first).
  • The edit half is Pdf::TextReplacer, driven per-document by
    PublicationTextReplaceWorker; Sunny's +pdf_find_replace+ tool ties them
    together with a dry-run → confirm flow.

Examples:

result = Publication::TextSearch.call('240V')
result.count   # => 14
result.samples # => [{ id:, sku:, name: }, …]

Defined Under Namespace

Classes: Result

Constant Summary collapse

SAMPLE_SIZE =

How many matching publications are sampled into Result#samples.

10

Class Method Summary collapse

Class Method Details

.call(query, scope: Item.publications) ⇒ Result

Parameters:

  • query (String)

    literal phrase (SQL LIKE metacharacters escaped)

  • scope (ActiveRecord::Relation) (defaults to: Item.publications)

    base relation to narrow (defaults to
    all publications; the tool passes active-only)

Returns:

Raises:

  • (ArgumentError)


33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# File 'app/services/publication/text_search.rb', line 33

def self.call(query, scope: Item.publications)
  raise ArgumentError, 'query must be present' if query.to_s.strip.empty?

  # Substring search on uploads.extracted_markdown is intentionally a
  # sequential scan: a leading-wildcard ILIKE can't use a b-tree index (and
  # no trigram index exists on purpose). That's acceptable here — the scan
  # is bounded by the relation's other filters (e.g. the active-only scope
  # the tool passes), the result set is capped downstream (SAMPLE_SIZE here,
  # FIND_REPLACE_MAX_ENQUEUE at enqueue time), and this is a dry-run
  # blast-radius check, not a hot path.
  relation = matching_relation(query, scope)

  Result.new(
    count:    relation.count,
    samples:  relation.limit(SAMPLE_SIZE).pluck(:id, :sku, :name).map { |id, sku, name| { id:, sku:, name: } },
    relation:
  )
end